Parcourir la source

Merge commit '125942965d9ff86e925048caf7b7a80aa6aeecb8' into wjunhao/gitflow_230425_03de

Signed-off-by: Junhao Wang <[email protected]>

# Conflicts:
#	scripts/o3de.py
#	scripts/o3de/o3de/utils.py
Junhao Wang il y a 2 ans
Parent
commit
a0fb0b8312
74 fichiers modifiés avec 2928 ajouts et 926 suppressions
  1. 4 0
      Code/Tools/ProjectManager/Resources/BannerBlue.svg
  2. 4 0
      Code/Tools/ProjectManager/Resources/BannerGreen.svg
  3. 4 0
      Code/Tools/ProjectManager/Resources/Hidden.svg
  4. 4 0
      Code/Tools/ProjectManager/Resources/ProjectManager.qrc
  5. 49 5
      Code/Tools/ProjectManager/Resources/ProjectManager.qss
  6. 7 0
      Code/Tools/ProjectManager/Resources/Visible.svg
  7. 33 18
      Code/Tools/ProjectManager/Source/CreateProjectCtrl.cpp
  8. 1 1
      Code/Tools/ProjectManager/Source/EngineScreenCtrl.cpp
  9. 3 4
      Code/Tools/ProjectManager/Source/ExternalLinkDialog.cpp
  10. 1 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogHeaderWidget.cpp
  11. 1 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogHeaderWidget.h
  12. 60 18
      Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogScreen.cpp
  13. 4 4
      Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogScreen.h
  14. 76 16
      Code/Tools/ProjectManager/Source/GemCatalog/GemFilterWidget.cpp
  15. 1 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemFilterWidget.h
  16. 10 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemInfo.cpp
  17. 6 0
      Code/Tools/ProjectManager/Source/GemCatalog/GemInfo.h
  18. 82 8
      Code/Tools/ProjectManager/Source/GemCatalog/GemInspector.cpp
  19. 7 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemInspector.h
  20. 3 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemItemDelegate.cpp
  21. 1 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemItemDelegate.h
  22. 1 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemListHeaderWidget.cpp
  23. 2 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemListHeaderWidget.h
  24. 220 64
      Code/Tools/ProjectManager/Source/GemCatalog/GemModel.cpp
  25. 15 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemModel.h
  26. 23 2
      Code/Tools/ProjectManager/Source/GemCatalog/GemSortFilterProxyModel.cpp
  27. 7 1
      Code/Tools/ProjectManager/Source/GemCatalog/GemSortFilterProxyModel.h
  28. 9 1
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoInfo.h
  29. 72 7
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoInspector.cpp
  30. 28 3
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoInspector.h
  31. 85 30
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoItemDelegate.cpp
  32. 20 9
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoItemDelegate.h
  33. 143 29
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoModel.cpp
  34. 14 6
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoModel.h
  35. 44 0
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoProxyModel.cpp
  36. 27 0
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoProxyModel.h
  37. 137 32
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoScreen.cpp
  38. 11 5
      Code/Tools/ProjectManager/Source/GemRepo/GemRepoScreen.h
  39. 4 0
      Code/Tools/ProjectManager/Source/GemsSubWidget.cpp
  40. 7 2
      Code/Tools/ProjectManager/Source/LinkWidget.cpp
  41. 3 1
      Code/Tools/ProjectManager/Source/LinkWidget.h
  42. 1 0
      Code/Tools/ProjectManager/Source/NewProjectSettingsScreen.cpp
  43. 4 1
      Code/Tools/ProjectManager/Source/ProjectInfo.h
  44. 2 1
      Code/Tools/ProjectManager/Source/ProjectManagerDefs.h
  45. 3 2
      Code/Tools/ProjectManager/Source/ProjectManagerWindow.cpp
  46. 47 2
      Code/Tools/ProjectManager/Source/ProjectUtils.cpp
  47. 163 189
      Code/Tools/ProjectManager/Source/ProjectsScreen.cpp
  48. 6 3
      Code/Tools/ProjectManager/Source/ProjectsScreen.h
  49. 211 104
      Code/Tools/ProjectManager/Source/PythonBindings.cpp
  50. 13 10
      Code/Tools/ProjectManager/Source/PythonBindings.h
  51. 31 12
      Code/Tools/ProjectManager/Source/PythonBindingsInterface.h
  52. 27 1
      Code/Tools/ProjectManager/Source/ScreenWidget.h
  53. 1 0
      Code/Tools/ProjectManager/Source/ScreensCtrl.h
  54. 2 1
      Code/Tools/ProjectManager/Source/TemplateInfo.cpp
  55. 8 2
      Code/Tools/ProjectManager/Source/UpdateProjectCtrl.cpp
  56. 2 0
      Code/Tools/ProjectManager/project_manager_files.cmake
  57. 6 6
      Code/Tools/ProjectManager/tests/MockPythonBindings.h
  58. 10 0
      cmake/Platform/Common/Install_common.cmake
  59. 1 1
      cmake/Subdirectories.cmake
  60. 1 0
      cmake/install/engine.json.in
  61. 3 0
      engine.json
  62. 6 1
      scripts/o3de.py
  63. 9 1
      scripts/o3de/o3de/compatibility.py
  64. 85 40
      scripts/o3de/o3de/download.py
  65. 74 0
      scripts/o3de/o3de/git_utils.py
  66. 29 5
      scripts/o3de/o3de/github_utils.py
  67. 9 6
      scripts/o3de/o3de/gitproviderinterface.py
  68. 75 8
      scripts/o3de/o3de/manifest.py
  69. 67 9
      scripts/o3de/o3de/project_manager_interface.py
  70. 299 140
      scripts/o3de/o3de/repo.py
  71. 44 19
      scripts/o3de/o3de/utils.py
  72. 219 21
      scripts/o3de/tests/test_download.py
  73. 21 9
      scripts/o3de/tests/test_project_manager_interface.py
  74. 216 56
      scripts/o3de/tests/test_repo.py

+ 4 - 0
Code/Tools/ProjectManager/Resources/BannerBlue.svg

@@ -0,0 +1,4 @@
+<svg width="121" height="29" viewBox="0 0 121 29" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0.984863 1.11133C0.984863 0.559043 1.43258 0.111328 1.98486 0.111328H120.985L111.518 13.6986L120.985 28.1113H1.98486C1.43258 28.1113 0.984863 27.6636 0.984863 27.1113V1.11133Z" fill="#2170EB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2147 17.7642L17.7591 20.5071L16.5531 15.3376L20.5681 11.8595L15.281 11.4109L13.2147 6.53564L11.1484 11.4109L5.86133 11.8595L9.87627 15.3376L8.67032 20.5071L13.2147 17.7642Z" fill="white"/>
+</svg>

+ 4 - 0
Code/Tools/ProjectManager/Resources/BannerGreen.svg

@@ -0,0 +1,4 @@
+<svg width="121" height="28" viewBox="0 0 121 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0.984863 1C0.984863 0.447715 1.43258 0 1.98486 0H120.985L111.518 13.5873L120.985 28H1.98486C1.43258 28 0.984863 27.5523 0.984863 27V1Z" fill="#1BA266"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.98485 8C8.44066 8 7.16897 9.16668 7.00316 10.6667H6.98485C6.98485 10.6667 6.7368 12.7598 8.31818 15.3954C9.89956 18.031 12.9848 20.6667 12.9848 20.6667C12.9848 20.6667 16.2369 17.7531 17.6515 15.3954C18.5347 13.9234 18.8348 12.4461 18.9357 11.544C18.968 11.3676 18.9848 11.1858 18.9848 11C18.9848 10.9657 18.9843 10.9315 18.9831 10.8975C18.9877 10.7487 18.9848 10.6667 18.9848 10.6667H18.9665C18.8007 9.16668 17.529 8 15.9848 8C14.4407 8 13.169 9.16668 13.0032 10.6667H12.9665C12.8007 9.16668 11.529 8 9.98485 8Z" fill="white"/>
+</svg>

+ 4 - 0
Code/Tools/ProjectManager/Resources/Hidden.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5248 5.52239L10.9409 5.10544C9.75061 4.36848 8.76688 4 7.98967 4C6.59117 4 4.53023 5.19308 1.80685 7.57925L1.3335 8C2.76635 9.29128 4.02561 10.2699 5.11129 10.9359L5.90249 10.1447C5.34572 9.60007 5.00016 8.84039 5.00016 8C5.00016 6.34315 6.34331 5 8.00016 5C8.84055 5 9.60024 5.34556 10.1449 5.90233L10.5248 5.52239ZM9.44216 6.60504C9.07791 6.23181 8.56873 6 8.00528 6C6.89789 6 6.00016 6.89543 6.00016 8C6.00016 8.56385 6.23409 9.07321 6.61043 9.43677L9.44216 6.60504ZM6.43032 11.6279C7.01897 11.876 7.53876 12 7.98967 12C9.38817 12 11.4554 10.8069 14.1913 8.42074L14.6668 8C13.7571 7.18397 12.9173 6.4928 12.1474 5.92648L10.8875 7.18292C10.9609 7.44268 11.0002 7.71676 11.0002 8C11.0002 9.65685 9.65702 11 8.00016 11C7.71403 11 7.43725 10.9599 7.17513 10.8851L6.43032 11.6279ZM10.0096 8.05849C9.9791 9.11671 9.12451 9.96898 8.06353 9.99917L10.0096 8.05849Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.633 1.41458L13.6521 2.42911L2.3228 13.7273L1.34644 12.7011L12.633 1.41458Z" fill="white"/>
+</svg>

+ 4 - 0
Code/Tools/ProjectManager/Resources/ProjectManager.qrc

@@ -53,5 +53,9 @@
         <file>Cloud.svg</file>
         <file>Cloud_Hover.svg</file>
         <file>error.svg</file>
+        <file>Visible.svg</file>
+        <file>Hidden.svg</file>
+        <file>BannerBlue.svg</file>
+        <file>BannerGreen.svg</file>
     </qresource>
 </RCC>

+ 49 - 5
Code/Tools/ProjectManager/Resources/ProjectManager.qss

@@ -30,6 +30,10 @@ QPushButton[primary="true"]:pressed {
     background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
                             stop: 0 #0085e2, stop: 1.0 #0e60db);
 }
+QPushButton[primary="true"]:disabled {
+    background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
+                            stop: 0 #777777, stop: 1.0 #444444);
+}
 
 QPushButton[secondary="true"] {
     qproperty-flat: true;
@@ -470,8 +474,10 @@ QTabBar::tab:focus {
     max-height: 28px;
     min-width: 150px;
     margin-right:30px;
+    font-weight: 600px;
 }
 
+
 #dialogSubTitle {
     font-size:14px;
     font-weight:600;
@@ -485,6 +491,12 @@ QTabBar::tab:focus {
     color: #888888;
 }
 
+#ExternalLinkDialog #externalLink {
+    color: #94D2FF;
+    margin:5px 0 30px 0;
+    font-size:12px;
+}
+
 /************** Project Settings **************/
 #projectSettings {
     margin-top:30px;
@@ -836,7 +848,8 @@ QProgressBar::chunk {
 }
 
 /* be specific here to override the general QLabel margins */
-#GemCatalogInspector #GemCatalogRequirements {
+#GemCatalogInspector #GemCatalogRequirements,
+#GemCatalogInspector #GemCatalogCompatibilityWarning {
    margin:0 0 10px 0; 
    padding-left:34px;
    min-height: 34px;
@@ -845,6 +858,10 @@ QProgressBar::chunk {
    background:transparent url(:/Info.svg) no-repeat left center;
 }
 
+#GemCatalogInspector #GemCatalogCompatibilityWarning {
+   background:transparent url(:/Warning.svg) no-repeat left center;
+}
+
 #GemCatalogCartOverlayGemDownloadHeader {
     margin:0;
     padding: 0px;
@@ -1052,10 +1069,6 @@ QProgressBar::chunk {
     font-weight:600;
 }
 
-#gemRepoInspector {
-    background: #444444;
-}
-
 #gemRepoAddDialogInstructionTitleLabel {
     font-size:14px;
     font-weight:bold;
@@ -1072,6 +1085,26 @@ QProgressBar::chunk {
 
 /************** Gem Repo Inspector **************/
 
+#gemRepoInspector {
+    background: #444444;
+}
+
+#gemRepoInspector QLabel {
+    margin:0;
+}
+
+/* IMPORTANT DO NOT REMOVE min/max-width 
+ * FlowLayout inside TagWidgetContainer can provide 
+ * incorrect height when the width isn't static and the
+ * tag widgets have padding (see #TagWidget)
+ * resulting in bottom widgets getting cut off
+ */
+#gemRepoInspector #TagWidgetContainer {
+    min-width:210px;
+    max-width:210px;
+    margin:0;
+}
+
 #gemRepoInspectorNameLabel {
     font-size: 18px;
     color: #FFFFFF;
@@ -1087,6 +1120,17 @@ QProgressBar::chunk {
     color: #FFFFFF;
 }
 
+#gemRepoInspector QPushButton {
+    qproperty-flat: true;
+    min-height:24px;
+    max-height:24px;
+    border-radius: 3px;
+    text-align:center;
+    font-size:12px;
+    font-weight:600;
+    margin-top:10px;
+}
+
 /************** Download object dialogs **************/
 
 #addRemoteTemplateDialog #formFrame {

+ 7 - 0
Code/Tools/ProjectManager/Resources/Visible.svg

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.9409 5.10544C9.75061 4.36848 8.76688 4 7.98967 4C6.59117 4 4.53023 5.19308 1.80685 7.57925L1.3335 8C2.76663 9.29153 4.02611 10.2703 5.11193 10.9363L5.90291 10.1451C5.34589 9.60045 5.00016 8.8406 5.00016 8C5.00016 6.34315 6.34331 5 8.00016 5C8.84076 5 9.60061 5.34573 10.1453 5.90275L10.9409 5.10544Z" fill="white"/>
+<path d="M14.1913 8.42071L14.6669 7.99997C13.7248 7.15495 12.8577 6.44382 12.0657 5.86658L10.8557 7.07747C10.9495 7.3681 11.0002 7.67812 11.0002 7.99997C11.0002 9.65682 9.65704 11 8.00018 11C7.67833 11 7.36831 10.9493 7.07768 10.8555L6.34326 11.5905C6.96786 11.8635 7.51667 12 7.98969 12C9.3882 12 11.4554 10.8069 14.1913 8.42071Z" fill="white"/>
+<path d="M5.05963 5.10544C6.24988 4.36848 7.23361 4 8.01081 4C9.40932 4 11.4703 5.19308 14.1936 7.57925L14.6669 7.99997C13.2337 9.2915 11.9744 10.2703 10.8886 10.9363L10.0976 10.1451C10.6546 9.60045 11.0002 8.84057 11.0002 7.99997C11.0002 6.34312 9.65702 5 8.00016 5C7.15956 5 6.39987 5.34573 5.85521 5.90275L5.05963 5.10544Z" fill="white"/>
+<path d="M1.8092 8.42071L1.3335 8C2.27559 7.15498 3.14279 6.44382 3.9348 5.86658L5.14481 7.07747C5.05099 7.3681 5.00016 7.67815 5.00016 8C5.00016 9.65685 6.34333 11 8.00018 11C8.32204 11 8.63218 10.9493 8.92281 10.8555L9.65723 11.5905C9.03263 11.8635 8.48382 12 8.01079 12C6.61229 12 4.5451 10.8069 1.8092 8.42071Z" fill="white"/>
+<path d="M7.99487 10.0001C6.88747 10.0001 5.98975 9.10462 5.98975 8.00005C5.98975 6.89548 6.88747 6 7.99487 6L8.00013 6.00003L8.00472 6.00003C9.11212 6.00003 10.0105 6.89543 10.0105 8C10.0105 9.10457 9.1128 10.0001 8.0054 10.0001L8.00013 10L7.99487 10.0001Z" fill="white"/>
+</svg>

+ 33 - 18
Code/Tools/ProjectManager/Source/CreateProjectCtrl.cpp

@@ -57,7 +57,6 @@ namespace O3DE::ProjectManager
         vLayout->addWidget(m_stack);
 
         connect(m_projectGemCatalogScreen, &ScreenWidget::ChangeScreenRequest, this, &CreateProjectCtrl::OnChangeScreenRequest);
-        connect(m_gemRepoScreen, &GemRepoScreen::OnRefresh, m_projectGemCatalogScreen, &ProjectGemCatalogScreen::Refresh);
         connect(static_cast<ScreensCtrl*>(parent), &ScreensCtrl::NotifyProjectRemoved, m_projectGemCatalogScreen, &GemCatalogScreen::NotifyProjectRemoved);
 
 
@@ -224,6 +223,11 @@ namespace O3DE::ProjectManager
             if(auto outcome = CurrentScreenIsValid(); outcome.IsSuccess())
             {
                 m_stack->setCurrentIndex(m_stack->currentIndex() + 1);
+                ScreenWidget* currentScreen = static_cast<ScreenWidget*>(m_stack->currentWidget());
+                if (currentScreen)
+                {
+                    currentScreen->NotifyCurrentScreen();
+                }
                 Update();
             }
             else if (!outcome.GetError().isEmpty())
@@ -243,6 +247,11 @@ namespace O3DE::ProjectManager
         if (m_stack->currentIndex() > 0)
         {
             m_stack->setCurrentIndex(m_stack->currentIndex() - 1);
+            ScreenWidget* currentScreen = static_cast<ScreenWidget*>(m_stack->currentWidget());
+            if (currentScreen)
+            {
+                currentScreen->NotifyCurrentScreen();
+            }
             Update();
         }
     }
@@ -276,29 +285,35 @@ namespace O3DE::ProjectManager
             ProjectInfo projectInfo = m_newProjectSettingsScreen->GetProjectInfo();
             QString projectTemplatePath = m_newProjectSettingsScreen->GetProjectTemplatePath();
 
-            auto result = PythonBindingsInterface::Get()->CreateProject(projectTemplatePath, projectInfo);
-            if (result.IsSuccess())
+            // create in 2 steps for better error handling
+            auto createResult = PythonBindingsInterface::Get()->CreateProject(projectTemplatePath, projectInfo, /*registerProject*/false);
+            if (!createResult)
             {
-                // don't need to register here, the project is already registered in CreateProject()
+                const IPythonBindings::ErrorPair& error = createResult.GetError();
+                ProjectUtils::DisplayDetailedError(tr("Failed to create project"), error.first, error.second, this);
+                return;
+            }
 
-                const ProjectGemCatalogScreen::ConfiguredGemsResult gemResult = m_projectGemCatalogScreen->ConfigureGemsForProject(projectInfo.m_path);
-                if (gemResult == ProjectGemCatalogScreen::ConfiguredGemsResult::Failed)
-                {
-                    QMessageBox::critical(this, tr("Failed to configure gems"), tr("Failed to configure gems for template."));
-                }
-                if (gemResult != ProjectGemCatalogScreen::ConfiguredGemsResult::Success)
-                {
-                    return;
-                }
+            // RegisterProject will check compatibility and prompt user to continue if issues found
+            // it will also handle detailed error messaging
+            if(!ProjectUtils::RegisterProject(projectInfo.m_path, this))
+            {
+                return;
+            }
 
-                projectInfo.m_needsBuild = true;
-                emit NotifyBuildProject(projectInfo);
-                emit ChangeScreenRequest(ProjectManagerScreen::Projects);
+            const ProjectGemCatalogScreen::ConfiguredGemsResult gemResult = m_projectGemCatalogScreen->ConfigureGemsForProject(projectInfo.m_path);
+            if (gemResult == ProjectGemCatalogScreen::ConfiguredGemsResult::Failed)
+            {
+                QMessageBox::critical(this, tr("Failed to configure gems"), tr("Failed to configure gems for template."));
             }
-            else
+            if (gemResult != ProjectGemCatalogScreen::ConfiguredGemsResult::Success)
             {
-                QMessageBox::critical(this, tr("Project creation failed"), tr("Failed to create project."));
+                return;
             }
+
+            projectInfo.m_needsBuild = true;
+            emit NotifyBuildProject(projectInfo);
+            emit ChangeScreenRequest(ProjectManagerScreen::Projects);
         }
         else
         {

+ 1 - 1
Code/Tools/ProjectManager/Source/EngineScreenCtrl.cpp

@@ -35,7 +35,7 @@ namespace O3DE::ProjectManager
         m_tabWidget->tabBar()->setFocusPolicy(Qt::TabFocus);
 
         m_engineSettingsScreen = new EngineSettingsScreen();
-        m_gemRepoScreen = new GemRepoScreen();
+        m_gemRepoScreen = new GemRepoScreen(parent);
 
         m_tabWidget->addTab(m_engineSettingsScreen, tr("General"));
         m_tabWidget->addTab(m_gemRepoScreen, tr("Remote Sources"));

+ 3 - 4
Code/Tools/ProjectManager/Source/ExternalLinkDialog.cpp

@@ -60,12 +60,11 @@ namespace O3DE::ProjectManager
         QLabel* bodyLabel = new QLabel(tr("If you trust this source, you can proceed to this link, or click \"Cancel\" to return."));
         layout->addWidget(bodyLabel);
 
-        // Don't actually set linkUrl we are just using LinkLabel superficially here
-        LinkLabel* linkLabel = new LinkLabel(url.toString(), {}, 12);
+        QLabel* linkLabel = new QLabel(url.toString());
+        linkLabel->setObjectName("externalLink");
+        linkLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
         layout->addWidget(linkLabel);
 
-        layout->addSpacing(40);
-
         QCheckBox* skipDialogCheckbox = new QCheckBox(tr("Do not show this again"));
         layout->addWidget(skipDialogCheckbox);
         connect(skipDialogCheckbox, &QCheckBox::stateChanged, this, &ExternalLinkDialog::SetSkipDialogSetting);

+ 1 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogHeaderWidget.cpp

@@ -538,7 +538,7 @@ namespace O3DE::ProjectManager
         hLayout->addSpacing(16);
 
         QMenu* gemMenu = new QMenu(this);
-        gemMenu->addAction( tr("Refresh"), [this]() { emit RefreshGems(); });
+        gemMenu->addAction(tr("Refresh"), [this]() { emit RefreshGems(/*refreshRemoteRepos*/true); });
         gemMenu->addAction( tr("Show Gem Repos"), [this]() { emit OpenGemsRepo(); });
         gemMenu->addSeparator();
         gemMenu->addAction( tr("Add Existing Gem"), [this]() { emit AddGem(); });

+ 1 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogHeaderWidget.h

@@ -109,7 +109,7 @@ namespace O3DE::ProjectManager
         void AddGem();
         void CreateGem();
         void OpenGemsRepo();
-        void RefreshGems();
+        void RefreshGems(bool refreshRemoteRepos);
         void UpdateGemCart(QWidget* gemCart);
 
     protected slots:

+ 60 - 18
Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogScreen.cpp

@@ -19,6 +19,7 @@
 #include <GemCatalog/GemUpdateDialog.h>
 #include <GemCatalog/GemUninstallDialog.h>
 #include <GemCatalog/GemItemDelegate.h>
+#include <GemRepo/GemRepoScreen.h>
 #include <DownloadController.h>
 #include <ProjectUtils.h>
 #include <AdjustableHeaderWidget.h>
@@ -79,7 +80,11 @@ namespace O3DE::ProjectManager
         connect(m_headerWidget, &GemCatalogHeaderWidget::UpdateGemCart, this, &GemCatalogScreen::UpdateAndShowGemCart);
         connect(m_downloadController, &DownloadController::Done, this, &GemCatalogScreen::OnGemDownloadResult);
 
-        SetUpScreensControl(parent);
+        ScreensCtrl* screensCtrl = GetScreensCtrl(this);
+        if (screensCtrl)
+        {
+            connect(screensCtrl, &ScreensCtrl::NotifyRemoteContentRefreshed, [this]() { m_needRefresh = true; });
+        }
 
         QHBoxLayout* hLayout = new QHBoxLayout();
         hLayout->setMargin(0);
@@ -101,6 +106,7 @@ namespace O3DE::ProjectManager
         connect(m_gemInspector, &GemInspector::UninstallGem, this, &GemCatalogScreen::UninstallGem);
         connect(m_gemInspector, &GemInspector::EditGem, this, &GemCatalogScreen::HandleEditGem);
         connect(m_gemInspector, &GemInspector::DownloadGem, this, &GemCatalogScreen::DownloadGem);
+        connect(m_gemInspector, &GemInspector::ShowToastNotification, this, &GemCatalogScreen::ShowStandardToastNotification);
 
         QWidget* filterWidget = new QWidget(this);
         filterWidget->setFixedWidth(sidePanelWidth);
@@ -123,7 +129,7 @@ namespace O3DE::ProjectManager
         constexpr int minHeaderSectionWidth = AZStd::min(previewWidth, AZStd::min(versionWidth, statusWidth));
 
         AdjustableHeaderWidget* listHeaderWidget = new AdjustableHeaderWidget(
-            QStringList{ tr("Gem Image"), tr("Gem Name"), tr("Gem Summary"), tr("Version"), tr("Status") },
+            QStringList{ tr("Gem Image"), tr("Gem Name"), tr("Gem Summary"), tr("Latest Version"), tr("Status") },
             QVector<int>{ previewWidth,
                           GemItemDelegate::s_defaultSummaryStartX - previewWidth,
                           0, // Section is set to stretch to fit
@@ -159,9 +165,10 @@ namespace O3DE::ProjectManager
         hLayout->addWidget(m_rightPanelStack);
         m_rightPanelStack->addWidget(m_gemInspector);
 
-        m_notificationsView = AZStd::make_unique<AzToolsFramework::ToastNotificationsView>(this, AZ_CRC("GemCatalogNotificationsView"));
+        m_notificationsView = AZStd::make_unique<AzToolsFramework::ToastNotificationsView>(this, AZ_CRC_CE("GemCatalogNotificationsView"));
         m_notificationsView->SetOffset(QPoint(10, 70));
         m_notificationsView->SetMaxQueuedNotifications(1);
+        m_notificationsView->SetRejectDuplicates(false); // we want to show notifications if a user repeats actions
     }
 
     void GemCatalogScreen::SetUpScreensControl(QWidget* parent)
@@ -192,9 +199,14 @@ namespace O3DE::ProjectManager
             // init the read only catalog the first time it is shown
             ReinitForProject(m_projectPath);
         }
+        else if (m_needRefresh)
+        {
+            // generally we need to refresh because remote repos were updated
+            m_needRefresh = false;
+            Refresh();
+        }
     }
 
-
     void GemCatalogScreen::NotifyProjectRemoved(const QString& projectPath)
     {
         // Use QFileInfo because the project path might be the project folder
@@ -294,7 +306,7 @@ namespace O3DE::ProjectManager
         }
     }
 
-    void GemCatalogScreen::Refresh()
+    void GemCatalogScreen::Refresh(bool refreshRemoteRepos)
     {
         QSet<QPersistentModelIndex> validIndexes;
 
@@ -304,6 +316,11 @@ namespace O3DE::ProjectManager
             validIndexes = QSet(indexes.cbegin(), indexes.cend());
         }
 
+        if(refreshRemoteRepos)
+        {
+            PythonBindingsInterface::Get()->RefreshAllGemRepos();
+        }
+
         if (const auto& outcome = PythonBindingsInterface::Get()->GetGemInfosForAllRepos(); outcome.IsSuccess())
         {
             const auto& indexes = m_gemModel->AddGems(outcome.GetValue(), /*updateExisting=*/true);
@@ -355,7 +372,7 @@ namespace O3DE::ProjectManager
 
     void GemCatalogScreen::OnGemStatusChanged(const QString& gemName, uint32_t numChangedDependencies) 
     {
-        if (m_notificationsEnabled)
+        if (m_notificationsEnabled && !m_readOnly)
         {
             auto modelIndex = m_gemModel->FindIndexByNameString(gemName);
             bool added = GemModel::IsAdded(modelIndex);
@@ -375,6 +392,7 @@ namespace O3DE::ProjectManager
                 const QString& newVersion = GemModel::GetNewVersion(modelIndex);
                 const QString& version = newVersion.isEmpty() ? gemInfo.m_version : newVersion;
 
+
                 // avoid showing the version twice if it's already in the display name
                 if (gemInfo.m_isEngineGem || (version.isEmpty() || gemInfo.m_displayName.contains(version) || version.contains("Unknown", Qt::CaseInsensitive)))
                 {
@@ -389,11 +407,25 @@ namespace O3DE::ProjectManager
                 {
                     notification += tr(" and ");
                 }
-                if (added && (GemModel::GetDownloadStatus(modelIndex) == GemInfo::DownloadStatus::NotDownloaded) ||
-                    (GemModel::GetDownloadStatus(modelIndex) == GemInfo::DownloadStatus::DownloadFailed))
+
+                if (added)
                 {
-                    m_downloadController->AddObjectDownload(GemModel::GetName(modelIndex), "", DownloadController::DownloadObjectType::Gem);
-                    GemModel::SetDownloadStatus(*m_gemModel, modelIndex, GemInfo::DownloadStatus::Downloading);
+                    if (newVersion.isEmpty() && (GemModel::GetDownloadStatus(modelIndex) == GemInfo::DownloadStatus::NotDownloaded) ||
+                        (GemModel::GetDownloadStatus(modelIndex) == GemInfo::DownloadStatus::DownloadFailed))
+                    {
+                        // download the current version
+                        DownloadGem(modelIndex, gemInfo.m_version, gemInfo.m_path);
+                    }
+                    else if (!newVersion.isEmpty())
+                    {
+                        const GemInfo& newVersionGemInfo = GemModel::GetGemInfo(modelIndex, newVersion);
+                        if (newVersionGemInfo.m_downloadStatus == GemInfo::DownloadStatus::NotDownloaded ||
+                            GemModel::GetDownloadStatus(modelIndex) == GemInfo::DownloadStatus::DownloadFailed)
+                        {
+                            // download the new version
+                            DownloadGem(modelIndex, newVersionGemInfo.m_version, newVersionGemInfo.m_path);
+                        }
+                    }
                 }
             }
 
@@ -451,9 +483,9 @@ namespace O3DE::ProjectManager
         ShowInspector();
     }
 
-    void GemCatalogScreen::UpdateGem(const QModelIndex& modelIndex)
+    void GemCatalogScreen::UpdateGem(const QModelIndex& modelIndex, const QString& version, const QString& path)
     {
-        const GemInfo& gemInfo = m_gemModel->GetGemInfo(modelIndex);
+        const GemInfo& gemInfo = m_gemModel->GetGemInfo(modelIndex, version, path);
 
         if (!gemInfo.m_repoUri.isEmpty())
         {
@@ -486,21 +518,30 @@ namespace O3DE::ProjectManager
             }
         }
 
+        // include the version if valid
+        auto outcome = AZ::SemanticVersion::ParseFromString(version.toUtf8().constData());
+        const QString gemName = outcome ? QString("%1==%2").arg(gemInfo.m_name, version) : gemInfo.m_name;
+
         // Check if there is an update avaliable now that repo is refreshed
-        bool updateAvaliable = PythonBindingsInterface::Get()->IsGemUpdateAvaliable(gemInfo.m_name, gemInfo.m_lastUpdatedDate);
+        bool updateAvaliable = PythonBindingsInterface::Get()->IsGemUpdateAvaliable(gemName, gemInfo.m_lastUpdatedDate);
 
         GemUpdateDialog* confirmUpdateDialog = new GemUpdateDialog(gemInfo.m_name, updateAvaliable, this);
         if (confirmUpdateDialog->exec() == QDialog::Accepted)
         {
-            m_downloadController->AddObjectDownload(gemInfo.m_name, "" , DownloadController::DownloadObjectType::Gem);
+            DownloadGem(modelIndex, version, path);
         }
     }
 
     void GemCatalogScreen::DownloadGem(const QModelIndex& modelIndex, const QString& version, const QString& path)
     {
-        const QString gemDisplayName = m_gemModel->GetDisplayName(modelIndex);
         const GemInfo& gemInfo = m_gemModel->GetGemInfo(modelIndex, version, path);
-        m_downloadController->AddObjectDownload(gemInfo.m_name, "" , DownloadController::DownloadObjectType::Gem);
+
+        // include the version if valid
+        auto outcome = AZ::SemanticVersion::ParseFromString(version.toUtf8().constData());
+        const QString gemName = outcome ? QString("%1==%2").arg(gemInfo.m_name, version) : gemInfo.m_name;
+        m_downloadController->AddObjectDownload(gemName, "" , DownloadController::DownloadObjectType::Gem);
+
+        GemModel::SetDownloadStatus(*m_gemModel, modelIndex, GemInfo::DownloadStatus::Downloading);
     }
 
     void GemCatalogScreen::UninstallGem(const QModelIndex& modelIndex, const QString& path)
@@ -629,7 +670,7 @@ namespace O3DE::ProjectManager
         {
             m_gemModel->AddGems(allGemInfosResult.GetValue());
 
-            const auto& allRepoGemInfosResult = PythonBindingsInterface::Get()->GetGemInfosForAllRepos();
+            const auto& allRepoGemInfosResult = PythonBindingsInterface::Get()->GetGemInfosForAllRepos(projectPath);
             if (allRepoGemInfosResult.IsSuccess())
             {
                 m_gemModel->AddGems(allRepoGemInfosResult.GetValue());
@@ -716,7 +757,8 @@ namespace O3DE::ProjectManager
 
     void GemCatalogScreen::OnGemDownloadResult(const QString& gemName, bool succeeded)
     {
-        const auto index = m_gemModel->FindIndexByNameString(gemName);
+        QString gemNameWithoutVersionSpecifier =  ProjectUtils::GetDependencyName(gemName);
+        const auto index = m_gemModel->FindIndexByNameString(gemNameWithoutVersionSpecifier);
         if (succeeded)
         {
             Refresh();

+ 4 - 4
Code/Tools/ProjectManager/Source/GemCatalog/GemCatalogScreen.h

@@ -47,19 +47,18 @@ namespace O3DE::ProjectManager
 
         void AddToGemModel(const GemInfo& gemInfo);
 
-        void ShowStandardToastNotification(const QString& notification);
-
         GemModel* GetGemModel() const { return m_gemModel; }
         DownloadController* GetDownloadController() const { return m_downloadController; }
 
     public slots:
+        void ShowStandardToastNotification(const QString& notification);
         void OnGemStatusChanged(const QString& gemName, uint32_t numChangedDependencies);
         void OnDependencyGemStatusChanged(const QString& gemName);
         void OnAddGemClicked();
         void SelectGem(const QString& gemName);
         void OnGemDownloadResult(const QString& gemName, bool succeeded = true);
-        void Refresh();
-        void UpdateGem(const QModelIndex& modelIndex);
+        void Refresh(bool refreshRemoteRepos = false);
+        void UpdateGem(const QModelIndex& modelIndex, const QString& version, const QString& path);
         void UninstallGem(const QModelIndex& modelIndex, const QString& path);
         void DownloadGem(const QModelIndex& modelIndex, const QString& version, const QString& path);
         void HandleGemCreated(const GemInfo& gemInfo);
@@ -105,6 +104,7 @@ namespace O3DE::ProjectManager
         bool m_notificationsEnabled = true;
         QString m_projectPath;
         bool m_readOnly;
+        bool m_needRefresh = false;
 
         QModelIndex m_curEditedIndex;
 

+ 76 - 16
Code/Tools/ProjectManager/Source/GemCatalog/GemFilterWidget.cpp

@@ -344,15 +344,15 @@ namespace O3DE::ProjectManager
         connect(m_filterProxyModel, &GemSortFilterProxyModel::OnInvalidated, this, &GemFilterWidget::OnFilterProxyInvalidated);
     }
 
-    void ClearButtonCheckBoxes(FilterCategoryWidget* widget)
+    void ResetButtonCheckBoxes(FilterCategoryWidget* widget)
     {
         for (const auto& button : widget->GetButtonGroup()->buttons())
         {
-            button->setChecked(false);
+            button->setChecked(button->property("selected_by_default").isValid());
         }
     }
 
-    void GemFilterWidget::UpdateAllFilters(bool clearCheckBoxes)
+    void GemFilterWidget::UpdateAllFilters(bool resetCheckBoxes)
     {
         UpdateGemStatusFilter();
         UpdateVersionsFilter();
@@ -361,35 +361,78 @@ namespace O3DE::ProjectManager
         UpdateFeatureFilter();
         UpdatePlatformFilter();
 
-        if (clearCheckBoxes)
+        if (resetCheckBoxes)
         {
-            ClearButtonCheckBoxes(m_statusFilter);
-            ClearButtonCheckBoxes(m_versionsFilter);
-            ClearButtonCheckBoxes(m_originFilter);
-            ClearButtonCheckBoxes(m_typeFilter);
-            ClearButtonCheckBoxes(m_featureFilter);
-            ClearButtonCheckBoxes(m_platformFilter);
+            ResetButtonCheckBoxes(m_statusFilter);
+            ResetButtonCheckBoxes(m_versionsFilter);
+            ResetButtonCheckBoxes(m_originFilter);
+            ResetButtonCheckBoxes(m_typeFilter);
+            ResetButtonCheckBoxes(m_featureFilter);
+            ResetButtonCheckBoxes(m_platformFilter);
         }
     }
 
     void GemFilterWidget::UpdateVersionsFilter()
     {
         int numGemsWithUpdates = 0;
+        int numCompatibleGems = 0;
+
+        // check the state of the compatible filter to see if we should be
+        // including updates for incompatible versions
+        bool compatibleUpdatesOnly = true; // on by default
+        QList<QAbstractButton*> buttons = m_versionsFilter->GetButtonGroup()->buttons();
+        if (buttons.count() >= 2)
+        {
+            const QAbstractButton* compatibleButton = buttons[1];
+            compatibleUpdatesOnly = compatibleButton->isChecked();
+        }
+
         for (int i = 0; i < m_gemModel->rowCount(); ++i)
         {
-            numGemsWithUpdates += GemModel::HasUpdates(m_gemModel->index(i, 0)) ? 1 : 0;
+            numGemsWithUpdates += GemModel::HasUpdates(m_gemModel->index(i, 0), compatibleUpdatesOnly) ? 1 : 0;
+            numCompatibleGems += GemModel::IsCompatible(m_gemModel->index(i, 0)) ? 1 : 0;
         }
 
-        m_versionsFilter->SetElements({ "Update Available" }, { numGemsWithUpdates });
+        m_versionsFilter->SetElements({ "Update Available", "Compatible" }, { numGemsWithUpdates, numCompatibleGems });
+
+        if (buttons.isEmpty())
+        {
+            // buttons were just created so make sure to set the selected_by_default property
+            // for the checkboxes we want to be on by default
+            buttons = m_versionsFilter->GetButtonGroup()->buttons();
+            QAbstractButton* compatibleButton = buttons[1];
+            compatibleButton->setProperty("selected_by_default", true);
+        }
     }
 
-    void GemFilterWidget::OnUpdateFilterToggled([[maybe_unused]] QAbstractButton* button, bool checked)
+    void GemFilterWidget::OnUpdateFilterToggled(QAbstractButton* button, bool checked)
     {
-        // for now we just have the one filter
-        m_filterProxyModel->SetUpdateAvailable(checked);
+        const QList<QAbstractButton*> buttons = m_versionsFilter->GetButtonGroup()->buttons();
+
+        if(button == buttons[0])
+        {
+            m_filterProxyModel->SetUpdateAvailable(checked);
+        }
+
+        if(button == buttons[1])
+        {
+            if(checked)
+            {
+                // have the gem model update the current gems with compatible
+                // versions in case the user was looking at incompatible gems
+                // and compatible gems exist
+                m_gemModel->ShowCompatibleGems();
+            }
+
+            // when the compatibility filter is changed we need to update the
+            // counts for the "Updates Available"
+            UpdateVersionsFilter();
+
+            m_filterProxyModel->SetCompatibleFilterFlag(checked);
+        }
     }
 
-    void GemFilterWidget::OnStatusFilterToggled([[maybe_unused]] QAbstractButton* button, [[maybe_unused]] bool checked)
+    void GemFilterWidget::OnStatusFilterToggled(QAbstractButton* button, bool checked)
     {
         const QList<QAbstractButton*> buttons = m_statusFilter->GetButtonGroup()->buttons();
         QAbstractButton* selectedButton = buttons[0];
@@ -428,6 +471,12 @@ namespace O3DE::ProjectManager
         {
             m_filterProxyModel->SetGemActive(GemSortFilterProxyModel::GemActive::NoFilter);
         }
+
+        // Missing
+        if (button == buttons[4])
+        {
+            m_filterProxyModel->SetGemMissing(checked);
+        }
     }
 
     void GemFilterWidget::UpdateGemStatusFilter()
@@ -460,6 +509,14 @@ namespace O3DE::ProjectManager
         elementNames.push_back(GemSortFilterProxyModel::GetGemActiveString(GemSortFilterProxyModel::GemActive::Inactive));
         elementCounts.push_back(totalGems - enabledGemTotal);
 
+        elementNames.push_back(tr("Missing"));
+        int numMissingGems = 0;
+        for (int i = 0; i < m_gemModel->rowCount(); ++i)
+        {
+            numMissingGems += GemModel::IsAddedMissing(m_gemModel->index(i, 0)) ? 1 : 0;
+        }
+        elementCounts.push_back(numMissingGems);
+
         m_statusFilter->SetElements(elementNames, elementCounts);
 
         const QList<QAbstractButton*> buttons = m_statusFilter->GetButtonGroup()->buttons();
@@ -472,6 +529,9 @@ namespace O3DE::ProjectManager
         QAbstractButton* inactiveButton = buttons[3];
         activeButton->setChecked(m_filterProxyModel->GetGemActive() == GemSortFilterProxyModel::GemActive::Active);
         inactiveButton->setChecked(m_filterProxyModel->GetGemActive() == GemSortFilterProxyModel::GemActive::Inactive);
+
+        QAbstractButton* missingButton = buttons[4];
+        missingButton->setChecked(m_filterProxyModel->GetMissingActive());
     }
 
     void GemFilterWidget::UpdateGemOriginFilter()

+ 1 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemFilterWidget.h

@@ -90,7 +90,7 @@ namespace O3DE::ProjectManager
         explicit GemFilterWidget(GemSortFilterProxyModel* filterProxyModel, QWidget* parent = nullptr);
         ~GemFilterWidget() = default;
 
-        void UpdateAllFilters(bool clearCheckBoxes = true);
+        void UpdateAllFilters(bool resetCheckBoxes = true);
 
     private slots:
         void OnStatusFilterToggled(QAbstractButton* button, bool checked);

+ 10 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemInfo.cpp

@@ -26,7 +26,16 @@ namespace O3DE::ProjectManager
     
     bool GemInfo::IsValid() const
     {
-        return !m_name.isEmpty() && !m_path.isEmpty();
+        // remote gems may not have a path because they haven't been downloaded
+        const bool isValidRemoteGem = (m_gemOrigin == Remote && m_downloadStatus == NotDownloaded);
+        return !m_name.isEmpty() && (!m_path.isEmpty() || isValidRemoteGem);
+    }
+
+    bool GemInfo::IsCompatible() const
+    {
+        const bool hasNoIncompatibleDependencies = m_incompatibleEngineDependencies.isEmpty()
+                                                && m_incompatibleGemDependencies.isEmpty();
+        return m_isEngineGem || hasNoIncompatibleDependencies;
     }
 
     QString GemInfo::GetPlatformString(Platform platform)

+ 6 - 0
Code/Tools/ProjectManager/Source/GemCatalog/GemInfo.h

@@ -72,6 +72,7 @@ namespace O3DE::ProjectManager
         bool IsPlatformSupported(Platform platform) const;
         QString GetNameWithVersionSpecifier(const QString& comparator = "==") const;
         bool IsValid() const;
+        bool IsCompatible() const;
 
         bool operator<(const GemInfo& gemInfo) const;
 
@@ -103,6 +104,11 @@ namespace O3DE::ProjectManager
         int m_binarySizeInKB = 0;
         QStringList m_dependencies;
         QStringList m_compatibleEngines;
+        QStringList m_incompatibleEngineDependencies; //! Specific to the current project's engine 
+        QStringList m_incompatibleGemDependencies; //! Specific to the current project and engine
+        QString m_downloadSourceUri;
+        QString m_sourceControlUri;
+        QString m_sourceControlRef;
     };
 } // namespace O3DE::ProjectManager
 

+ 82 - 8
Code/Tools/ProjectManager/Source/GemCatalog/GemInspector.cpp

@@ -20,6 +20,8 @@
 #include <QIcon>
 #include <QPushButton>
 #include <QComboBox>
+#include <QClipboard>
+#include <QGuiApplication>
 
 namespace O3DE::ProjectManager
 {
@@ -158,8 +160,7 @@ namespace O3DE::ProjectManager
         m_summaryLabel->setText(gemInfo.m_summary);
         m_summaryLabel->adjustSize();
 
-        // Manually define remaining space to elide text because spacer would like to take all of the space
-        SetLabelElidedText(m_licenseLinkLabel, gemInfo.m_licenseText, width() - m_licenseLabel->width() - 35);
+        m_licenseLinkLabel->SetText(gemInfo.m_licenseText);
         m_licenseLinkLabel->SetUrl(gemInfo.m_licenseLink);
 
         m_directoryLinkLabel->SetUrl(gemInfo.m_directoryLink);
@@ -184,6 +185,7 @@ namespace O3DE::ProjectManager
 
         // Additional information
         m_lastUpdatedLabel->setText(tr("Last Updated: %1").arg(gemInfo.m_lastUpdatedDate));
+        m_copyDownloadLinkLabel->setVisible(!gemInfo.m_downloadSourceUri.isEmpty());
         if (gemInfo.m_binarySizeInKB)
         {
             m_binarySizeLabel->setText(tr("Binary Size:  %1 KB").arg(gemInfo.m_binarySizeInKB));
@@ -236,6 +238,26 @@ namespace O3DE::ProjectManager
             connect(m_versionComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &GemInspector::OnVersionChanged);
         }
 
+        m_compatibilityTextLabel->setVisible(!gemInfo.IsCompatible());
+        if(!gemInfo.IsCompatible())
+        {
+            if(!gemInfo.m_compatibleEngines.isEmpty())
+            {
+                if (m_readOnly)
+                {
+                    m_compatibilityTextLabel->setText(tr("This version is not known to be compatible with the current engine"));
+                }
+                else
+                {
+                    m_compatibilityTextLabel->setText(tr("This version is not known to be compatible with the engine this project uses"));
+                }
+            }
+            else
+            {
+                m_compatibilityTextLabel->setText(tr("This version has missing or incompatible gem dependencies"));
+            }
+        }
+
         // Compatible engines
         m_enginesTitleLabel->setVisible(!gemInfo.m_isEngineGem);
         m_enginesLabel->setVisible(!gemInfo.m_isEngineGem);
@@ -273,9 +295,9 @@ namespace O3DE::ProjectManager
 
         m_updateGemButton->setVisible(isRemote && isDownloaded && !isMissing);
         m_uninstallGemButton->setText(isRemote ? tr("Uninstall Gem") : tr("Remove Gem"));
-        m_uninstallGemButton->setVisible((isRemote && isDownloaded && !isMissing) || isLocal);
+        m_uninstallGemButton->setVisible(!isMissing && ((isRemote && isDownloaded) || isLocal));
         m_editGemButton->setVisible(!isMissing && (!isRemote || (isRemote && isDownloaded)));
-        m_downloadGemButton->setVisible(m_readOnly && isRemote && !isDownloaded);
+        m_downloadGemButton->setVisible(isRemote && !isDownloaded);
 
         m_mainWidget->adjustSize();
         m_mainWidget->show();
@@ -292,12 +314,43 @@ namespace O3DE::ProjectManager
     void GemInspector::OnVersionChanged([[maybe_unused]] int index)
     {
         Update(m_curModelIndex, GetVersion(), GetVersionPath());
-        if (!GemModel::IsAdded(m_curModelIndex))
+
+        // we don't update the version in the gem list when read only
+        // because it can cause the row to disappear due to changing filters
+        // but in a project-specific view it is necessary because
+        // the checkbox to activate the gem is on the row
+        if (!GemModel::IsAdded(m_curModelIndex) && !m_readOnly)
         {
             GemModel::UpdateWithVersion(*m_model, m_curModelIndex, GetVersion(), GetVersionPath());
         }
     }
 
+    void GemInspector::OnCopyDownloadLinkClicked()
+    {
+        const GemInfo& gemInfo = m_model->GetGemInfo(m_curModelIndex, GetVersion(), GetVersionPath());
+        if (!gemInfo.m_downloadSourceUri.isEmpty())
+        {
+            if(QClipboard* clipboard = QGuiApplication::clipboard(); clipboard != nullptr)
+            {
+                clipboard->setText(gemInfo.m_downloadSourceUri);
+
+                QString displayname = gemInfo.m_displayName.isEmpty() ? gemInfo.m_name : gemInfo.m_displayName;
+                if (gemInfo.m_version.isEmpty() || gemInfo.m_displayName.contains(gemInfo.m_version) || gemInfo.m_version.contains("Unknown", Qt::CaseInsensitive))
+                {
+                    emit ShowToastNotification(tr("%1 download URL copied to clipboard").arg(displayname));
+                }
+                else
+                {
+                    emit ShowToastNotification(tr("%1 %2 download URL copied to clipboard").arg(displayname, gemInfo.m_version));
+                }
+            }
+            else
+            {
+                emit ShowToastNotification("Failed to copy download URL to clipboard");
+            }
+        }
+    }
+
     QString GemInspector::GetVersion() const
     {
         return m_versionComboBox->count() > 0 ? m_versionComboBox->currentText() : m_model->GetVersion(m_curModelIndex);
@@ -342,9 +395,29 @@ namespace O3DE::ProjectManager
             connect(m_updateVersionButton, &QPushButton::clicked, this , [this]{
                 GemModel::SetIsAdded(*m_model, m_curModelIndex, true, GetVersion());
                 GemModel::UpdateWithVersion(*m_model, m_curModelIndex, GetVersion(), GetVersionPath());
+
+                const GemInfo& gemInfo = GemModel::GetGemInfo(m_curModelIndex);
+                if (!gemInfo.m_repoUri.isEmpty())
+                {
+                    // this gem comes from a remote repository, see if we should download it
+                    const auto downloadStatus = GemModel::GetDownloadStatus(m_curModelIndex);
+                    if (downloadStatus == GemInfo::NotDownloaded || downloadStatus == GemInfo::DownloadFailed) 
+                    {
+                        emit DownloadGem(m_curModelIndex, GetVersion(), GetVersionPath());
+                    }
+                }
+
                 m_updateVersionButton->setVisible(false);
             });
 
+            // Compatibility 
+            m_compatibilityTextLabel = new QLabel();
+            m_compatibilityTextLabel->setObjectName("GemCatalogCompatibilityWarning");
+            m_compatibilityTextLabel->setWordWrap(true);
+            m_compatibilityTextLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
+            m_compatibilityTextLabel->setOpenExternalLinks(true);
+            versionVLayout->addWidget(m_compatibilityTextLabel);
+
             m_enginesTitleLabel = new QLabel(tr("Compatible Engines: "));
             versionVLayout->addWidget(m_enginesTitleLabel);
             m_enginesLabel = new QLabel();
@@ -375,8 +448,6 @@ namespace O3DE::ProjectManager
             m_licenseLinkLabel = new LinkLabel("", QUrl(), s_baseFontSize);
             licenseHLayout->addWidget(m_licenseLinkLabel);
 
-            licenseHLayout->addStretch();
-
             m_mainLayout->addSpacing(5);
         }
 
@@ -423,6 +494,9 @@ namespace O3DE::ProjectManager
 
         m_lastUpdatedLabel = CreateStyledLabel(m_mainLayout, s_baseFontSize, s_textColor);
         m_binarySizeLabel = CreateStyledLabel(m_mainLayout, s_baseFontSize, s_textColor);
+        m_copyDownloadLinkLabel = new LinkLabel(tr("Copy Download URL"));
+        m_mainLayout->addWidget(m_copyDownloadLinkLabel);
+        connect(m_copyDownloadLinkLabel, &LinkLabel::clicked, this, &GemInspector::OnCopyDownloadLinkClicked);
 
         m_mainLayout->addSpacing(20);
 
@@ -438,7 +512,7 @@ namespace O3DE::ProjectManager
         m_updateGemButton = new QPushButton(tr("Update Gem"));
         m_updateGemButton->setProperty("secondary", true);
         m_mainLayout->addWidget(m_updateGemButton);
-        connect(m_updateGemButton, &QPushButton::clicked, this , [this]{ emit UpdateGem(m_curModelIndex); });
+        connect(m_updateGemButton, &QPushButton::clicked, this , [this]{ emit UpdateGem(m_curModelIndex, GetVersion(), GetVersionPath()); });
 
         m_mainLayout->addSpacing(10);
 

+ 7 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemInspector.h

@@ -69,15 +69,17 @@ namespace O3DE::ProjectManager
 
     signals:
         void TagClicked(const Tag& tag);
-        void UpdateGem(const QModelIndex& modelIndex);
+        void UpdateGem(const QModelIndex& modelIndex, const QString& version = "", const QString& path = "");
         void UninstallGem(const QModelIndex& modelIndex, const QString& path = "");
         void EditGem(const QModelIndex& modelIndex, const QString& path = "");
         void DownloadGem(const QModelIndex& modelIndex, const QString& version = "", const QString& path = "");
+        void ShowToastNotification(const QString& notification);
 
     private slots:
         void OnSelectionChanged(const QItemSelection& selected, const QItemSelection& deselected);
         void OnVersionChanged(int index);
         void OnDirSizeSet(QString size);
+        void OnCopyDownloadLinkClicked();
 
     private:
         void InitMainWidget();
@@ -105,6 +107,9 @@ namespace O3DE::ProjectManager
         // Requirements
         QLabel* m_requirementsTextLabel = nullptr;
 
+        // Compatibility
+        QLabel* m_compatibilityTextLabel = nullptr;
+
         // Depending gems
         GemsSubWidget* m_dependingGems = nullptr;
         QSpacerItem* m_dependingGemsSpacer = nullptr;
@@ -117,6 +122,7 @@ namespace O3DE::ProjectManager
         QLabel* m_enginesLabel = nullptr;
         QLabel* m_lastUpdatedLabel = nullptr;
         QLabel* m_binarySizeLabel = nullptr;
+        LinkLabel* m_copyDownloadLinkLabel = nullptr;
 
         QPushButton* m_updateVersionButton = nullptr;
         QPushButton* m_updateGemButton = nullptr;

+ 3 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemItemDelegate.cpp

@@ -172,7 +172,9 @@ namespace O3DE::ProjectManager
             gemVersionRect = painter->boundingRect(gemVersionRect, Qt::TextWordWrap | Qt::AlignRight | Qt::AlignVCenter, gemInfo.m_version);
             painter->drawText(gemVersionRect, Qt::TextWordWrap | Qt::AlignRight | Qt::AlignVCenter, gemInfo.m_version);
 
-            if (GemModel::HasUpdates(modelIndex))
+            GemSortFilterProxyModel* proxyModel = reinterpret_cast<GemSortFilterProxyModel*>(m_model);
+            bool showCompatibleUpdatesOnly = proxyModel ? proxyModel->GetCompatibleFilterFlag() : true;
+            if (GemModel::HasUpdates(modelIndex, showCompatibleUpdatesOnly))
             {
                 painter->drawPixmap(gemVersionRect.left() - s_statusButtonSpacing - m_updatePixmap.width(),
                                     contentRect.center().y() - m_updatePixmap.height() / 2,

+ 1 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemItemDelegate.h

@@ -76,7 +76,7 @@ namespace O3DE::ProjectManager
         inline constexpr static int s_platformTextWrapAroundLineMaxCount = 2;
 
         // Version
-        inline constexpr static int s_versionSize = 50;
+        inline constexpr static int s_versionSize = 70;
         inline constexpr static int s_versionSizeSpacing = 25;
 
         // Status icon

+ 1 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemListHeaderWidget.cpp

@@ -63,7 +63,7 @@ namespace O3DE::ProjectManager
 
         QPushButton* refreshButton = new QPushButton();
         refreshButton->setObjectName("RefreshButton");
-        connect( refreshButton, &QPushButton::clicked, [this] { emit OnRefresh(); });
+        connect(refreshButton, &QPushButton::clicked, [this] { emit OnRefresh(/*refreshRemoteRepos*/true); });
         topLayout->addWidget(refreshButton);
 
         auto refreshGemCountUI = [=]() {

+ 2 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemListHeaderWidget.h

@@ -24,7 +24,8 @@ namespace O3DE::ProjectManager
     public:
         explicit GemListHeaderWidget(GemSortFilterProxyModel* proxyModel, QWidget* parent = nullptr);
         ~GemListHeaderWidget() = default;
+
     signals:
-        void OnRefresh();
+        void OnRefresh(bool refreshRemoteRepos);
     };
 } // namespace O3DE::ProjectManager

+ 220 - 64
Code/Tools/ProjectManager/Source/GemCatalog/GemModel.cpp

@@ -49,7 +49,7 @@ namespace O3DE::ProjectManager
         }
     }
 
-    void AddGemInfoVersion(QStandardItem* item, const GemInfo& gemInfo, bool update)
+    bool AddGemInfoVersion(QStandardItem* item, const GemInfo& gemInfo, [[maybe_unused]] bool update)
     {
         QList<QVariant> versionList;
         auto variant = item->data(GemModel::RoleGemInfoVersions);
@@ -60,45 +60,42 @@ namespace O3DE::ProjectManager
         QVariant gemVariant;
         gemVariant.setValue(gemInfo);
 
-        // sanity check we aren't adding a gem with an existing path and version
         int versionToReplaceIndex = -1;
         for (int i = 0; i < versionList.size(); ++i)
         {
-            const QVariant& existingGemVariant = versionList.at(i);
-            const GemInfo& existingGemInfo = existingGemVariant.value<GemInfo>();
-            if (QDir(existingGemInfo.m_path) == QDir(gemInfo.m_path))
+            const GemInfo& existingGemInfo = versionList.at(i).value<GemInfo>();
+            if (existingGemInfo.m_version == gemInfo.m_version)
             {
-                if (existingGemInfo.m_version == gemInfo.m_version && !update)
+                if(existingGemInfo.m_downloadStatus == GemInfo::NotDownloaded ||
+                   existingGemInfo.m_downloadStatus == GemInfo::DownloadFailed)
                 {
-                    AZ_Info("ProjectManager", "Not adding GemInfo because a GemInfo with path (%s) and version (%s) already exists.",
-                        gemInfo.m_path.toUtf8().constData(),
-                        gemInfo.m_version.toUtf8().constData()
-                        );
-                    return;
-                }
+                    // gems that haven't been downloaded may have empty paths
+                    // always update data from the server
+                    versionToReplaceIndex = i;
+                    break;
 
-                // the path is the same but the version has changed, update the info
-                versionToReplaceIndex = i;
-                break;
+                    // once a gem has been downloaded we rely on the data on disk
+                    // and don't override it with remote data
+                }
+                else if (gemInfo.m_downloadStatus == GemInfo::NotDownloaded ||
+                         gemInfo.m_downloadStatus == GemInfo::DownloadFailed)
+                {
+                    // never overwrite a downloaded version with a remote version
+                    return false;
+                }
+                else if (QDir(existingGemInfo.m_path) == QDir(gemInfo.m_path))
+                {
+                    versionToReplaceIndex = i;
+                    break;
+                }
             }
-            else if (existingGemInfo.m_version == gemInfo.m_version &&
-                existingGemInfo.m_downloadStatus == GemInfo::NotDownloaded &&
-                gemInfo.m_downloadStatus == GemInfo::Downloaded)
+            else if (!existingGemInfo.m_path.isEmpty() && !gemInfo.m_path.isEmpty() &&
+                    QDir(existingGemInfo.m_path) == QDir(gemInfo.m_path))
             {
-                // we are adding  a gem version for a gem that has been downloaded
-                // so replace the content for remote gem
+                // data on disk changed and version don't match anymore 
                 versionToReplaceIndex = i;
                 break;
             }
-
-            if (existingGemInfo.m_version == gemInfo.m_version &&
-                existingGemInfo.m_downloadStatus == GemInfo::Downloaded &&
-                gemInfo.m_downloadStatus == GemInfo::NotDownloaded)
-            {
-                // do not add the not downloaded remote version of
-                // something we have downloaded
-                return;
-            }
         }
 
         if(versionToReplaceIndex != -1)
@@ -109,7 +106,22 @@ namespace O3DE::ProjectManager
         {
             versionList.append(gemVariant);
         }
+
+        // it's possible a remote gem with a higher version gets added after a downloaded gem with a lower version
+        // so sort by version if we have enough entries
+        if (versionList.size() > 1)
+        {
+            std::sort(
+                versionList.begin(),
+                versionList.end(),
+                [](const QVariant& a, const QVariant& b) -> bool
+                {
+                    return ProjectUtils::VersionCompare(a.value<GemInfo>().m_version, b.value<GemInfo>().m_version) > 0;
+                });
+        }
+
         item->setData(versionList, GemModel::RoleGemInfoVersions);
+        return true;
     }
 
     bool RemoveGemInfoVersion(QStandardItem* item, const QString& version, const QString& path)
@@ -142,6 +154,26 @@ namespace O3DE::ProjectManager
         return versionList.isEmpty();
     }
 
+    bool GemModel::ShouldUpdateItemDataFromGemInfo(const QModelIndex& modelIndex, const GemInfo& gemInfo)
+    {
+        // get the most compatible version or empty string if none are compatible
+        const QString mostCompatibleVersion = GetMostCompatibleVersion(modelIndex);
+        const bool newVersionIsCompatible = gemInfo.IsCompatible();
+        int versionResult = ProjectUtils::VersionCompare(gemInfo.m_version, modelIndex.data(RoleVersion).toString());
+
+        if (mostCompatibleVersion.isEmpty() && !newVersionIsCompatible)
+        {
+            // no compatible versions available (yet) so refresh if version is the same or higher
+            return versionResult >= 0;
+        }
+
+        const bool oldVersionIsCompatible = VersionIsCompatible(modelIndex, modelIndex.data(RoleVersion).toString());
+
+        return (versionResult > 0 && newVersionIsCompatible) || // new higher version is compatible
+               (versionResult == 0) ||                          // version the same
+               (!oldVersionIsCompatible && newVersionIsCompatible); // old version wasn't compatible but new is
+    }
+
     QVector<QPersistentModelIndex> GemModel::AddGems(const QVector<GemInfo>& gemInfos, bool updateExisting)
     {
         QVector<QPersistentModelIndex> indexesChanged;
@@ -167,16 +199,12 @@ namespace O3DE::ProjectManager
                 auto gemItem = itemFromIndex(modelIndex);
                 AZ_Assert(gemItem, "Failed to retrieve existing gem item from model index");
 
-                // if this is a greater version than the existing version
-                // or we are updating the existing version, update
-                int versionResult = ProjectUtils::VersionCompare(gemInfo.m_version, gemItem->data(RoleVersion).toString());
-                if (versionResult > 0 || (versionResult == 0 && updateExisting))
+                const bool updatedExistingInfo = AddGemInfoVersion(gemItem, gemInfo, updateExisting);
+                if (updatedExistingInfo && ShouldUpdateItemDataFromGemInfo(modelIndex, gemInfo))
                 {
                     SetItemDataFromGemInfo(gemItem, gemInfo, /*metaDataOnly=*/ true);
                 }
 
-                AddGemInfoVersion(gemItem, gemInfo, updateExisting);
-
                 indexesChanged.append(modelIndex);
             }
             else
@@ -215,9 +243,10 @@ namespace O3DE::ProjectManager
         {
             const QString& gemPath = itr.value();
             const QString& gemNameWithSpecifier = itr.key();
-            AZ::Dependency<AZ::SemanticVersion::parts_count> dependency;
-            auto parseOutcome = dependency.ParseVersions({ gemNameWithSpecifier.toUtf8().constData() });
-            const QString& gemName = parseOutcome ? dependency.GetName().c_str() : gemNameWithSpecifier; 
+
+            QString gemName, gemVersion;
+            ProjectUtils::Comparison comparator;
+            ProjectUtils::GetDependencyNameAndVersion(gemNameWithSpecifier, gemName, comparator, gemVersion);
             if (gemName == "${Name}")
             {
                 // ${Name} is a special name used in templates and is replaced with a real gem name later 
@@ -228,25 +257,29 @@ namespace O3DE::ProjectManager
             if (auto nameFoundIter = m_nameToIndexMap.find(gemName); nameFoundIter != m_nameToIndexMap.end())
             {
                 const QModelIndex modelIndex = nameFoundIter.value();
-                const auto& versionList = modelIndex.data(RoleGemInfoVersions).value<QList<QVariant>>();
-                if (versionList.count() > 1 && !gemPath.isEmpty())
+                QStandardItem* gemItem = itemFromIndex(modelIndex);
+                AZ_Assert(gemItem, "Failed to retrieve enabled gem item from model index");
+
+                GemInfo gemInfo = GetGemInfo(modelIndex, gemVersion, gemPath);
+                if (!gemInfo.IsValid())
                 {
-                    // make sure the gem item delegate displays the correct version info 
-                    for (auto versionVariant : versionList)
-                    {
-                        const auto& variantGemInfo = versionVariant.value<GemInfo>();
-                        if (QDir(gemPath) == QDir(variantGemInfo.m_path))
-                        {
-                            QStandardItem* gemItem = itemFromIndex(modelIndex);
-                            AZ_Assert(gemItem, "Failed to retrieve enabled gem item from model index");
-                            SetItemDataFromGemInfo(gemItem, variantGemInfo);
-                            break;
-                        }
-                    }
+                    // This gem version info is missing, but the project uses it so show it to the user
+                    // so they can remove it or change versions if they want to
+                    // In the future we want to let the user browse to this gem's location on disk, or
+                    // let them download it
+                    gemInfo.m_name = gemName;
+                    gemInfo.m_displayName = gemName;
+                    gemInfo.m_version = gemVersion;
+                    gemInfo.m_summary = QString("This project uses %1 but a compatible gem was not found, or has not been registered yet.")
+                                            .arg(gemNameWithSpecifier);
+                    gemInfo.m_isAdded = true;
+
+                    AddGemInfoVersion(gemItem, gemInfo, /*updateExisting=*/false);
                 }
 
-                // Set Added/PreviouslyAdded after potentially updating data above which might remove
-                // those settings
+                SetItemDataFromGemInfo(gemItem, gemInfo);
+
+                // Set Added/PreviouslyAdded after potentially updating data these settings
                 GemModel::SetWasPreviouslyAdded(*this, modelIndex, true);
                 GemModel::SetIsAdded(*this, modelIndex, true);
 
@@ -260,7 +293,7 @@ namespace O3DE::ProjectManager
             GemInfo gemInfo;
             gemInfo.m_name = gemName;
             gemInfo.m_displayName = gemName;
-            gemInfo.m_version = parseOutcome ? dependency.GetBounds().at(0).ToString().c_str() : "";
+            gemInfo.m_version = gemVersion;
             gemInfo.m_summary = QString("This project uses %1 but a compatible gem was not found, or has not been registered yet.").arg(gemNameWithSpecifier);
             gemInfo.m_isAdded = true;
 
@@ -368,9 +401,9 @@ namespace O3DE::ProjectManager
         {
             return {};
         }
-        else if (gemVersion.isEmpty() && version.isEmpty())
+        else if (gemVersion.isEmpty() && version.isEmpty() && path.isEmpty())
         {
-            // no version to look for so return the first GemInfo
+            // the currently displayed version has no version info so just return it
             return versionList.at(0).value<GemInfo>();
         }
 
@@ -381,16 +414,17 @@ namespace O3DE::ProjectManager
         {
             // there may be multiple instances of the same gem with the same version
             // at different paths
-            const QString& variantVersion = versionVariant.value<GemInfo>().m_version;
-            const QString& variantPath = versionVariant.value<GemInfo>().m_path;
+            const GemInfo& gemInfo = versionVariant.value<GemInfo>();
+            const QString& variantVersion = gemInfo.m_version;
+            const QString& variantPath = gemInfo.m_path;
 
             // if no version is provided, try to find the one that matches the current version
             // if a path and/or version is provided try to find an exact match
             if ((useCurrentVersion && gemVersion == variantVersion) ||
-                (usePath && variantPath == path) ||
+                (usePath && QFileInfo(variantPath) == QFileInfo(path)) ||
                 (!usePath && useVersion && variantVersion == version))
             {
-                return versionVariant.value<GemInfo>();
+                return gemInfo;
             }
         }
 
@@ -496,6 +530,33 @@ namespace O3DE::ProjectManager
         return modelIndex.data(RoleNewVersion).toString();
     }
 
+    QString GemModel::GetMostCompatibleVersion(const QModelIndex& modelIndex)
+    {
+        const auto& versionList = modelIndex.data(RoleGemInfoVersions).value<QList<QVariant>>();
+        if (versionList.isEmpty())
+        {
+            return {};
+        }
+
+        // versions are sorted from highest to lowest so return the first compatible version
+        for (const auto& versionVariant : versionList)
+        {
+            const GemInfo& variantGemInfo = versionVariant.value<GemInfo>();
+            if(variantGemInfo.IsCompatible())
+            {
+                return variantGemInfo.m_version;
+            }
+        }
+
+        // no compatible version found
+        return {};
+    }
+
+    bool GemModel::VersionIsCompatible(const QModelIndex& modelIndex, const QString& version)
+    {
+        return GemModel::GetGemInfo(modelIndex, version).IsCompatible();
+    }
+
     GemModel* GemModel::GetSourceModel(QAbstractItemModel* model)
     {
         GemSortFilterProxyModel* proxyModel = qobject_cast<GemSortFilterProxyModel*>(model);
@@ -738,7 +799,25 @@ namespace O3DE::ProjectManager
             {
                 SetIsAdded(model, dependentModelIndex, false);
             }
+        }
+    }
 
+    void GemModel::ShowCompatibleGems()
+    {
+        for (int row = 0; row < rowCount(); ++row)
+        {
+            const QModelIndex modelIndex = index(row, 0);
+            const GemInfo& gemInfo = GetGemInfo(modelIndex);
+            if (!gemInfo.IsCompatible() && !IsAdded(modelIndex))
+            {
+                // does a compatible version exist?
+                QString compatibleVersion = GetMostCompatibleVersion(modelIndex);
+                if(!compatibleVersion.isEmpty())
+                {
+                    // show the compatible version
+                    UpdateWithVersion(*this, modelIndex, compatibleVersion);
+                }
+            }
         }
     }
 
@@ -752,8 +831,9 @@ namespace O3DE::ProjectManager
         return !GemModel::GetGemInfo(modelIndex).m_requirement.isEmpty();
     }
 
-    bool GemModel::HasUpdates(const QModelIndex& modelIndex)
+    bool GemModel::HasUpdates(const QModelIndex& modelIndex, bool compatibleOnly)
     {
+        // get the currently displayed item
         const auto& gemInfo = GemModel::GetGemInfo(modelIndex);
         if (gemInfo.m_isEngineGem)
         {
@@ -764,13 +844,89 @@ namespace O3DE::ProjectManager
         const auto& versions = GemModel::GetGemVersions(modelIndex);
         if (versions.count() < 2)
         {
+            // there is only one version available
             return false;
         }
 
         auto currentVersion = modelIndex.data(RoleVersion).toString();
+        if (compatibleOnly)
+        {
+            // versions are ordered from highest to lowest
+            for (auto itr = versions.cbegin(); itr != versions.cend(); itr++)
+            {
+                const GemInfo& versionGemInfo = itr->value<GemInfo>();
+                if (versionGemInfo.IsCompatible())
+                {
+                    if (currentVersion != versionGemInfo.m_version)
+                    {
+                        return true;
+                    }
+
+                    // if this is a remote gem, show that the update is available if we
+                    // haven't downloaded it yet and the user has downloaded an older version
+                    if (gemInfo.m_gemOrigin == GemInfo::Remote && gemInfo.m_downloadStatus == GemInfo::NotDownloaded)
+                    {
+                        itr++;
+                        while (itr != versions.cend())
+                        {
+                            const GemInfo& olderVersionGemInfo = itr->value<GemInfo>();
+                            if (olderVersionGemInfo.m_version != currentVersion &&
+                                (olderVersionGemInfo.m_downloadStatus == GemInfo::DownloadSuccessful ||
+                                olderVersionGemInfo.m_downloadStatus == GemInfo::Downloaded))
+                            {
+                                // found an older version that was downloaded
+                                return true;
+                            }
+                            itr++;
+                        }
+
+                        // did not find an older version that was downloaded
+                        return false;
+                    }
 
-        // gem versions are sorted so we can just compare if we're using the latest version
-        return currentVersion != versions.at(0).value<GemInfo>().m_version;
+                    return currentVersion != versionGemInfo.m_version;
+                }
+            }
+            return false;
+        }
+        else
+        {
+            if (currentVersion != versions.at(0).value<GemInfo>().m_version)
+            {
+                return true;
+            }
+
+            // if this is a remote gem that hasn't been downloaded, show that the update is
+            // available if the user has downloaded an older version
+            if (gemInfo.m_gemOrigin == GemInfo::Remote &&
+                gemInfo.m_downloadStatus == GemInfo::NotDownloaded)
+            {
+                // we've already verified versions.count() > 1 above
+                for (int i = 1; i < versions.count(); ++i)
+                {
+                    const GemInfo& variantGemInfo  = versions.at(i).value<GemInfo>();
+                    if (variantGemInfo.m_version != currentVersion &&
+                        (variantGemInfo.m_downloadStatus == GemInfo::DownloadSuccessful ||
+                        variantGemInfo.m_downloadStatus == GemInfo::Downloaded))
+                    {
+                        // found an older version that was downloaded
+                        return true;
+                    }
+                }
+            }
+
+            return false;
+        }
+    }
+
+    bool GemModel::IsCompatible(const QModelIndex& modelIndex)
+    {
+        return GemModel::GetGemInfo(modelIndex).IsCompatible();
+    }
+
+    bool GemModel::IsAddedMissing(const QModelIndex& modelIndex)
+    {
+        return GemModel::IsAdded(modelIndex) && GemModel::GetGemInfo(modelIndex).m_path.isEmpty();
     }
 
     bool GemModel::DoGemsToBeAddedHaveRequirements() const

+ 15 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemModel.h

@@ -54,6 +54,14 @@ namespace O3DE::ProjectManager
         QVector<Tag> GetDependingGemTags(const QModelIndex& modelIndex);
         bool HasDependentGems(const QModelIndex& modelIndex) const;
 
+        /*
+         * Get the GemInfo for the currently displayed item if no version or path is specified,
+         * otherwise, return the gem info for the requested version and/or path
+         * @param modelIndex The model index for the gem
+         * @param version The optional version to find
+         * @param path The optional path to find, this is often preferred over version because it is possible the user
+         *             has multiple instances of the same gem registered
+         */
         static const GemInfo GetGemInfo(const QPersistentModelIndex& modelIndex, const QString& version = "", const QString& path = "");
         static const QList<QVariant> GetGemVersions(const QModelIndex& modelIndex);
         static QString GetName(const QModelIndex& modelIndex);
@@ -78,7 +86,9 @@ namespace O3DE::ProjectManager
         static bool NeedsToBeAdded(const QModelIndex& modelIndex, bool includeDependencies = false);
         static bool NeedsToBeRemoved(const QModelIndex& modelIndex, bool includeDependencies = false);
         static bool HasRequirement(const QModelIndex& modelIndex);
-        static bool HasUpdates(const QModelIndex& modelIndex);
+        static bool HasUpdates(const QModelIndex& modelIndex, bool compatibleOnly = true);
+        static bool IsCompatible(const QModelIndex& modelIndex);
+        static bool IsAddedMissing(const QModelIndex& modelIndex);
         static void UpdateDependencies(QAbstractItemModel& model, const QString& gemName, bool isAdded);
         static void UpdateWithVersion(
             QAbstractItemModel& model, const QPersistentModelIndex& modelIndex, const QString& version, const QString& path = "");
@@ -94,6 +104,7 @@ namespace O3DE::ProjectManager
         QVector<QModelIndex> GatherGemsToBeRemoved(bool includeDependencies = false) const;
 
         int TotalAddedGems(bool includeDependencies = false) const;
+        void ShowCompatibleGems();
 
     signals:
         void gemStatusChanged(const QString& gemName, uint32_t numChangedDependencies);
@@ -106,6 +117,9 @@ namespace O3DE::ProjectManager
     private:
         void GetAllDependingGems(const QModelIndex& modelIndex, QSet<QPersistentModelIndex>& inOutGems);
         QStringList GetDependingGems(const QModelIndex& modelIndex);
+        QString GetMostCompatibleVersion(const QModelIndex& modelIndex); 
+        bool VersionIsCompatible(const QModelIndex& modelIndex, const QString& version); 
+        bool ShouldUpdateItemDataFromGemInfo(const QModelIndex& modelIndex, const GemInfo& gemInfo);
 
         QHash<QString, QPersistentModelIndex> m_nameToIndexMap;
         QItemSelectionModel* m_selectionModel = nullptr;

+ 23 - 2
Code/Tools/ProjectManager/Source/GemCatalog/GemSortFilterProxyModel.cpp

@@ -30,7 +30,8 @@ namespace O3DE::ProjectManager
 
         const GemInfo& gemInfo = m_sourceModel->GetGemInfo(sourceIndex);
         // Search Bar
-        if (!gemInfo.m_displayName.contains(m_searchString, Qt::CaseInsensitive) &&
+        if (!m_searchString.isEmpty() &&
+            !gemInfo.m_displayName.contains(m_searchString, Qt::CaseInsensitive) &&
             !gemInfo.m_name.contains(m_searchString, Qt::CaseInsensitive) &&
             !gemInfo.m_origin.contains(m_searchString, Qt::CaseInsensitive) &&
             !gemInfo.m_summary.contains(m_searchString, Qt::CaseInsensitive))
@@ -77,7 +78,19 @@ namespace O3DE::ProjectManager
         }
 
         // Update available
-        if (m_updateAvailableFilter && !GemModel::HasUpdates(sourceIndex))
+        if (m_updateAvailableFilter && !GemModel::HasUpdates(sourceIndex, m_compatibleOnlyFilter))
+        {
+            return false;
+        }
+
+        // Compatible
+        if (m_compatibleOnlyFilter && !GemModel::IsCompatible(sourceIndex))
+        {
+            return false;
+        }
+
+        // Missing
+        if (m_gemMissingFilter && !GemModel::IsAddedMissing(sourceIndex))
         {
             return false;
         }
@@ -264,6 +277,8 @@ namespace O3DE::ProjectManager
         m_platformFilter = {};
         m_typeFilter = {};
         m_featureFilter = {};
+        m_updateAvailableFilter = false;
+        m_compatibleOnlyFilter = true;
 
         InvalidateFilter();
     }
@@ -273,4 +288,10 @@ namespace O3DE::ProjectManager
         m_updateAvailableFilter = showGemsWithUpdates;
         InvalidateFilter();
     }
+
+    void GemSortFilterProxyModel::SetCompatibleFilterFlag(bool showCompatibleGemsOnly)
+    {
+        m_compatibleOnlyFilter = showCompatibleGemsOnly;
+        InvalidateFilter();
+    }
 } // namespace O3DE::ProjectManager

+ 7 - 1
Code/Tools/ProjectManager/Source/GemCatalog/GemSortFilterProxyModel.h

@@ -57,6 +57,9 @@ namespace O3DE::ProjectManager
         GemActive GetGemActive() const { return m_gemActiveFilter; }
         void SetGemActive(GemActive enabled) { m_gemActiveFilter = enabled; InvalidateFilter(); }
 
+        bool GetMissingActive() const { return m_gemMissingFilter; }
+        void SetGemMissing(bool enabled) { m_gemMissingFilter = enabled; InvalidateFilter(); }
+
         GemInfo::GemOrigins GetGemOrigins() const { return m_gemOriginFilter; }
         void SetGemOrigins(const GemInfo::GemOrigins& gemOrigins) { m_gemOriginFilter = gemOrigins; InvalidateFilter(); }
 
@@ -69,7 +72,7 @@ namespace O3DE::ProjectManager
         const QSet<QString>& GetFeatures() const { return m_featureFilter; }
         void SetFeatures(const QSet<QString>& features) { m_featureFilter = features; InvalidateFilter(); }
 
-        bool GetUpdateAvailable() const { return m_updateAvailableFilter; }
+        bool GetCompatibleFilterFlag() const { return m_compatibleOnlyFilter; }
 
         void InvalidateFilter();
         void ResetFilters(bool clearSearchString = true);
@@ -78,6 +81,7 @@ namespace O3DE::ProjectManager
         void OnInvalidated();
 
     public slots:
+        void SetCompatibleFilterFlag(bool showCompatibleGemsOnly);
         void SetUpdateAvailable(bool showGemsWithUpdates);
         void SetTypeFilterFlag(int flag, bool set); 
         void SetPlatformFilterFlag(int flag, bool set); 
@@ -94,6 +98,8 @@ namespace O3DE::ProjectManager
         GemInfo::Platforms m_platformFilter = {};
         GemInfo::Types m_typeFilter = {};
         bool m_updateAvailableFilter = false;
+        bool m_compatibleOnlyFilter = true;
+        bool m_gemMissingFilter = false;
         QSet<QString> m_featureFilter;
     };
 } // namespace O3DE::ProjectManager

+ 9 - 1
Code/Tools/ProjectManager/Source/GemRepo/GemRepoInfo.h

@@ -29,6 +29,14 @@ namespace O3DE::ProjectManager
 
         bool operator<(const GemRepoInfo& gemRepoInfo) const;
 
+        enum class BadgeType
+        {
+            NoBadge = 0,
+            BlueBadge,
+            GreenBadge,
+            NumBadgeTypes
+        };
+
         QString m_path = "";
         QString m_name = "Unknown Repo Name";
         QString m_origin = "Unknown Creator";
@@ -37,7 +45,7 @@ namespace O3DE::ProjectManager
         QString m_additionalInfo = "";
         QString m_directoryLink = "";
         QString m_repoUri = "";
-        QStringList m_includedGemUris = {};
         QDateTime m_lastUpdated;
+        BadgeType m_badgeType = BadgeType::NoBadge;
     };
 } // namespace O3DE::ProjectManager

+ 72 - 7
Code/Tools/ProjectManager/Source/GemRepo/GemRepoInspector.cpp

@@ -9,17 +9,23 @@
 #include <GemRepo/GemRepoInspector.h>
 #include <GemRepo/GemRepoItemDelegate.h>
 #include <PythonBindingsInterface.h>
+#include <AzQtComponents/Components/Widgets/ElidingLabel.h>
 
 #include <QFrame>
 #include <QLabel>
 #include <QVBoxLayout>
 #include <QIcon>
+#include <QPushButton>
+#include <QClipboard>
+#include <QGuiApplication>
+#include <QItemSelectionModel>
 
 namespace O3DE::ProjectManager
 {
-    GemRepoInspector::GemRepoInspector(GemRepoModel* model, QWidget* parent)
+    GemRepoInspector::GemRepoInspector(GemRepoModel* model, QItemSelectionModel* selectionModel,QWidget* parent)
         : QScrollArea(parent)
         , m_model(model)
+        , m_selectionModel(selectionModel)
     {
         setObjectName("gemRepoInspector");
         setWidgetResizable(true);
@@ -36,7 +42,7 @@ namespace O3DE::ProjectManager
 
         InitMainWidget();
 
-        connect(m_model->GetSelectionModel(), &QItemSelectionModel::selectionChanged, this, &GemRepoInspector::OnSelectionChanged);
+        connect(selectionModel, &QItemSelectionModel::selectionChanged, this, &GemRepoInspector::OnSelectionChanged);
         Update({});
     }
 
@@ -54,6 +60,8 @@ namespace O3DE::ProjectManager
 
     void GemRepoInspector::Update(const QModelIndex& modelIndex)
     {
+        m_curModelIndex = modelIndex;
+
         if (!modelIndex.isValid())
         {
             m_mainWidget->hide();
@@ -63,7 +71,11 @@ namespace O3DE::ProjectManager
         m_nameLabel->setText(m_model->GetName(modelIndex));
 
         const QString repoUri = m_model->GetRepoUri(modelIndex);
-        m_repoLinkLabel->setText(repoUri);
+        // ideally we would use Qt::TextWrapAnywhere to wrap and display the full URL
+        // but QLabel only supports word-break wrapping so elide the text
+        // clicking on the link will display the full URL and ask the user
+        // to confirm they want to visit it
+        m_repoLinkLabel->SetText(repoUri);
         m_repoLinkLabel->SetUrl(repoUri);
 
         // Repo summary
@@ -89,7 +101,26 @@ namespace O3DE::ProjectManager
         }
 
         // Included Gems
-        m_includedGems->Update(tr("Included Gems"), "", m_model->GetIncludedGemTags(modelIndex));
+        const QVector<Tag>& gemTags = m_model->GetIncludedGemTags(modelIndex);
+        m_includedGems->setVisible(!gemTags.isEmpty());
+        if (!gemTags.empty())
+        {
+            m_includedGems->Update(tr("Included Gems"), "", gemTags);
+        }
+
+        const QVector<Tag>& projectTags = m_model->GetIncludedProjectTags(modelIndex);
+        m_includedProjects->setVisible(!projectTags.isEmpty());
+        if (!projectTags.empty())
+        {
+            m_includedProjects->Update(tr("Included Projects"), "", projectTags);
+        }
+
+        const QVector<Tag>& templateTags = m_model->GetIncludedProjectTemplateTags(modelIndex);
+        m_includedTemplates->setVisible(!templateTags.isEmpty());
+        if (!templateTags.empty())
+        {
+            m_includedTemplates->Update(tr("Included Project Templates"), "", templateTags);
+        }
 
         m_mainWidget->adjustSize();
         m_mainWidget->show();
@@ -98,12 +129,16 @@ namespace O3DE::ProjectManager
     void GemRepoInspector::InitMainWidget()
     {
         // Repo name and url link
-        m_nameLabel = new QLabel();
+        m_nameLabel = new AzQtComponents::ElidingLabel();
         m_nameLabel->setObjectName("gemRepoInspectorNameLabel");
+        m_nameLabel->setWordWrap(true);
         m_mainLayout->addWidget(m_nameLabel);
 
-        m_repoLinkLabel = new LinkLabel(tr("Repo Url"), QUrl(), 12, this);
+        m_repoLinkLabel = new LinkLabel(tr("Repo URL"), QUrl(), 12, this);
         m_mainLayout->addWidget(m_repoLinkLabel);
+        m_copyDownloadLinkLabel = new LinkLabel(tr("Copy Repo URL"));
+        m_mainLayout->addWidget(m_copyDownloadLinkLabel);
+        connect(m_copyDownloadLinkLabel, &LinkLabel::clicked, this, &GemRepoInspector::OnCopyDownloadLinkClicked);
         m_mainLayout->addSpacing(5);
 
         // Repo summary
@@ -136,12 +171,42 @@ namespace O3DE::ProjectManager
         m_mainLayout->addWidget(m_addInfoTextLabel);
 
         // Conditional spacing for additional info section
-        m_addInfoSpacer = new QSpacerItem(0, 0, QSizePolicy::Expanding);
+        m_addInfoSpacer = new QSpacerItem(0, 20, QSizePolicy::Fixed);
         m_mainLayout->addSpacerItem(m_addInfoSpacer);
 
         // Included Gems
         m_includedGems = new GemsSubWidget();
         m_mainLayout->addWidget(m_includedGems);
+
+        m_includedProjects = new GemsSubWidget();
+        m_mainLayout->addWidget(m_includedProjects);
+
+        m_includedTemplates = new GemsSubWidget();
+        m_mainLayout->addWidget(m_includedTemplates);
+
         m_mainLayout->addSpacing(20);
+
+        m_removeRepoButton = new QPushButton(tr("Remove"));
+        m_removeRepoButton->setProperty("danger", true);
+        m_mainLayout->addWidget(m_removeRepoButton);
+        connect(m_removeRepoButton, &QPushButton::clicked, this , [this]{ emit RemoveRepo(m_curModelIndex); });
+    }
+
+    void GemRepoInspector::OnCopyDownloadLinkClicked()
+    {
+        const auto& url = m_repoLinkLabel->GetUrl();
+
+        if (!url.toString().isEmpty())
+        {
+            if(QClipboard* clipboard = QGuiApplication::clipboard(); clipboard != nullptr)
+            {
+                clipboard->setText(url.toString());
+                emit ShowToastNotification(tr("%1 URL copied to clipboard").arg(m_nameLabel->text()));
+            }
+            else
+            {
+                emit ShowToastNotification("Failed to copy URL to clipboard");
+            }
+        }
     }
 } // namespace O3DE::ProjectManager

+ 28 - 3
Code/Tools/ProjectManager/Source/GemRepo/GemRepoInspector.h

@@ -17,10 +17,18 @@
 #include <QScrollArea>
 #include <QSpacerItem>
 #include <QWidget>
+#include <QPersistentModelIndex>
 #endif
 
 QT_FORWARD_DECLARE_CLASS(QVBoxLayout)
 QT_FORWARD_DECLARE_CLASS(QLabel)
+QT_FORWARD_DECLARE_CLASS(QPushButton)
+QT_FORWARD_DECLARE_CLASS(QItemSelectionModel)
+
+namespace AzQtComponents
+{
+    class ElidingLabel;
+}
 
 namespace O3DE::ProjectManager
 {
@@ -28,24 +36,34 @@ namespace O3DE::ProjectManager
     {
         Q_OBJECT
 
-            public : explicit GemRepoInspector(GemRepoModel* model, QWidget* parent = nullptr);
+    public:
+
+        explicit GemRepoInspector(GemRepoModel* model, QItemSelectionModel* selectionModel, QWidget* parent = nullptr);
         ~GemRepoInspector() = default;
 
         void Update(const QModelIndex& modelIndex);
 
+    signals:
+        void RemoveRepo(const QModelIndex& modelIndex);
+        void ShowToastNotification(const QString& notification);
+
     private slots:
         void OnSelectionChanged(const QItemSelection& selected, const QItemSelection& deselected);
+        void OnCopyDownloadLinkClicked();
 
     private:
         void InitMainWidget();
 
+
         GemRepoModel* m_model = nullptr;
+        QItemSelectionModel* m_selectionModel = nullptr;
         QWidget* m_mainWidget = nullptr;
         QVBoxLayout* m_mainLayout = nullptr;
 
         // General info section
-        QLabel* m_nameLabel = nullptr;
+        AzQtComponents::ElidingLabel* m_nameLabel = nullptr;
         LinkLabel* m_repoLinkLabel = nullptr;
+        LinkLabel* m_copyDownloadLinkLabel = nullptr;
         QLabel* m_summaryLabel = nullptr;
 
         // Additional information
@@ -53,7 +71,14 @@ namespace O3DE::ProjectManager
         QLabel* m_addInfoTextLabel = nullptr;
         QSpacerItem* m_addInfoSpacer = nullptr;
 
-        // Included Gems
+        // Buttons
+        QPushButton* m_removeRepoButton = nullptr;
+
+        // Included objects 
         GemsSubWidget* m_includedGems = nullptr;
+        GemsSubWidget* m_includedProjects = nullptr;
+        GemsSubWidget* m_includedTemplates = nullptr;
+
+        QModelIndex m_curModelIndex; 
     };
 } // namespace O3DE::ProjectManager

+ 85 - 30
Code/Tools/ProjectManager/Source/GemRepo/GemRepoItemDelegate.cpp

@@ -15,6 +15,7 @@
 #include <QPainter>
 #include <QMouseEvent>
 #include <QHeaderView>
+#include <QLocale>
 
 namespace O3DE::ProjectManager
 {
@@ -26,6 +27,10 @@ namespace O3DE::ProjectManager
         m_refreshIcon   = QIcon(":/Refresh.svg").pixmap(s_refreshIconSize, s_refreshIconSize);
         m_editIcon      = QIcon(":/Edit.svg").pixmap(s_iconSize, s_iconSize);
         m_deleteIcon    = QIcon(":/Delete.svg").pixmap(s_iconSize, s_iconSize);
+        m_hiddenIcon    = QIcon(":/Hidden.svg").pixmap(s_iconSize, s_iconSize);
+        m_visibleIcon   = QIcon(":/Visible.svg").pixmap(s_iconSize, s_iconSize);
+        m_blueBadge   = QIcon(":/BannerBlue.svg").pixmap(s_badgeWidth, s_badgeHeight);
+        m_greenBadge   = QIcon(":/BannerGreen.svg").pixmap(s_badgeWidth, s_badgeHeight);
     }
 
     void GemRepoItemDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& modelIndex) const
@@ -47,6 +52,11 @@ namespace O3DE::ProjectManager
         standardFont.setPixelSize(static_cast<int>(s_fontSize));
         QFontMetrics standardFontMetrics(standardFont);
 
+        QFont standardBoldFont(options.font);
+        standardBoldFont.setPixelSize(static_cast<int>(s_fontSize));
+        standardBoldFont.setBold(true);
+        QFontMetrics standardFontBoldMetrics(standardFont);
+
         painter->save();
         painter->setClipping(true);
         painter->setClipRect(fullRect);
@@ -72,22 +82,22 @@ namespace O3DE::ProjectManager
             painter->restore();
         }
 
-        int currentHorizontalOffset = CalcColumnXBounds(HeaderOrder::Name).first;
+        int currentHorizontalOffset = CalcColumnXBounds(HeaderOrder::Name).first + s_contentMargins.left() ;
 
         // Repo name
         QString repoName = GemRepoModel::GetName(modelIndex);
-        int sectionSize = m_headerWidget->m_header->sectionSize(static_cast<int>(HeaderOrder::Name));
+        int sectionSize = m_headerWidget->m_header->sectionSize(static_cast<int>(HeaderOrder::Name)) - s_contentMargins.left();
         repoName = standardFontMetrics.elidedText(repoName, Qt::TextElideMode::ElideRight,
             sectionSize - AdjustableHeaderWidget::s_headerTextIndent);
 
         QRect repoNameRect = GetTextRect(standardFont, repoName, s_fontSize);
-        repoNameRect.moveTo(currentHorizontalOffset + AdjustableHeaderWidget::s_headerTextIndent,
+        repoNameRect.moveTo(currentHorizontalOffset,
             contentRect.center().y() - repoNameRect.height() / 2);
         repoNameRect = painter->boundingRect(repoNameRect, Qt::TextSingleLine, repoName);
 
         painter->drawText(repoNameRect, Qt::TextSingleLine, repoName);
 
-        // Rem repo creator
+        // Repo creator
         currentHorizontalOffset += sectionSize;
         sectionSize = m_headerWidget->m_header->sectionSize(static_cast<int>(HeaderOrder::Creator));
 
@@ -102,14 +112,56 @@ namespace O3DE::ProjectManager
 
         painter->drawText(repoCreatorRect, Qt::TextSingleLine, repoCreator);
 
-        // Repo update
+        // Badge
+        currentHorizontalOffset += sectionSize;
+        sectionSize = m_headerWidget->m_header->sectionSize(static_cast<int>(HeaderOrder::Badge));
+        auto badgeType = GemRepoModel::GetBadgeType(modelIndex);
+        const QPixmap* badge = nullptr;
+        QString badgeText;
+        if (badgeType == GemRepoInfo::BadgeType::BlueBadge)
+        {
+            badge = &m_blueBadge;
+            badgeText = tr("O3DE Official");
+        }
+        else if (badgeType == GemRepoInfo::BadgeType::GreenBadge)
+        {
+            badge = &m_greenBadge;
+
+            // this text should be made dynamic at some point
+            badgeText = tr("O3DF Recommended");
+        }
+
+        if (badge)
+        {
+            const QRect badgeRect = CalcBadgeRect(contentRect);
+            painter->drawPixmap(badgeRect, m_blueBadge);
+
+            painter->setFont(standardBoldFont);
+
+            QRect badgeLabelRect = GetTextRect(standardBoldFont, badgeText, s_fontSize);
+            badgeLabelRect.moveTo(currentHorizontalOffset + s_badgeLeftMargin,
+                contentRect.center().y() - (badgeLabelRect.height() / 2) - 1);
+            badgeLabelRect = painter->boundingRect(badgeLabelRect, Qt::TextSingleLine, badgeText);
+            painter->drawText(badgeLabelRect, Qt::TextSingleLine, badgeText);
+
+            painter->setFont(standardFont);
+        }
+
+        // Last updated
         currentHorizontalOffset += sectionSize;
-        sectionSize = m_headerWidget->m_header->sectionSize(static_cast<int>(HeaderOrder::Update));
+        sectionSize = m_headerWidget->m_header->sectionSize(static_cast<int>(HeaderOrder::Updated));
+        auto lastUpdated = GemRepoModel::GetLastUpdated(modelIndex);
 
-        QString repoUpdatedDate = GemRepoModel::GetLastUpdated(modelIndex).toString(RepoTimeFormat);
+        // get the month day and year in the preferred locale's format (QLocale defaults to the OS locale)
+        QString monthDayYear = lastUpdated.toString(QLocale().dateFormat(QLocale::ShortFormat));
+
+        // always show 12 hour + minutes + am/pm
+        QString hourMinuteAMPM = lastUpdated.toString("h:mmap");
+
+        QString repoUpdatedDate = QString("%1 %2").arg(monthDayYear, hourMinuteAMPM);
         repoUpdatedDate = standardFontMetrics.elidedText(
             repoUpdatedDate, Qt::TextElideMode::ElideRight,
-            sectionSize - GemRepoItemDelegate::s_refreshIconSpacing - GemRepoItemDelegate::s_refreshIconSize - AdjustableHeaderWidget::s_headerTextIndent);
+            sectionSize - AdjustableHeaderWidget::s_headerTextIndent);
 
         QRect repoUpdatedDateRect = GetTextRect(standardFont, repoUpdatedDate, s_fontSize);
         repoUpdatedDateRect.moveTo(currentHorizontalOffset + AdjustableHeaderWidget::s_headerTextIndent,
@@ -118,14 +170,13 @@ namespace O3DE::ProjectManager
 
         painter->drawText(repoUpdatedDateRect, Qt::TextSingleLine, repoUpdatedDate);
 
-        // Draw refresh button
+        // Refresh button
         const QRect refreshButtonRect = CalcRefreshButtonRect(contentRect);
         painter->drawPixmap(refreshButtonRect.topLeft(), m_refreshIcon);
 
-        if (options.state & QStyle::State_MouseOver)
-        {
-            DrawEditButtons(painter, contentRect);
-        }
+        // Visibility button
+        const QRect visibilityButtonRect = CalcVisibilityButtonRect(contentRect);
+        painter->drawPixmap(visibilityButtonRect, GemRepoModel::IsEnabled(modelIndex) ? m_visibleIcon  : m_hiddenIcon);
 
         painter->restore();
     }
@@ -136,7 +187,7 @@ namespace O3DE::ProjectManager
         initStyleOption(&options, modelIndex);
 
         const int marginsHorizontal = s_itemMargins.left() + s_itemMargins.right() + s_contentMargins.left() + s_contentMargins.right();
-        return QSize(marginsHorizontal + s_nameDefaultWidth + s_creatorDefaultWidth + s_updatedDefaultWidth, s_height);
+        return QSize(marginsHorizontal + s_nameDefaultWidth + s_creatorDefaultWidth + s_buttonsDefaultWidth, s_height);
     }
 
     bool GemRepoItemDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& modelIndex)
@@ -168,12 +219,13 @@ namespace O3DE::ProjectManager
 
             QRect fullRect, itemRect, contentRect;
             CalcRects(option, fullRect, itemRect, contentRect);
-            const QRect deleteButtonRect = CalcDeleteButtonRect(contentRect);
+            const QRect visibilityButtonRect = CalcVisibilityButtonRect(contentRect);
             const QRect refreshButtonRect = CalcRefreshButtonRect(contentRect);
 
-            if (deleteButtonRect.contains(mouseEvent->pos()))
+            if (visibilityButtonRect.contains(mouseEvent->pos()))
             {
-                emit RemoveRepo(modelIndex);
+                bool isAdded = GemRepoModel::IsEnabled(modelIndex);
+                GemRepoModel::SetEnabled(*model, modelIndex, !isAdded);
                 return true;
             }
             else if (refreshButtonRect.contains(mouseEvent->pos()))
@@ -204,26 +256,29 @@ namespace O3DE::ProjectManager
         return m_headerWidget->CalcColumnXBounds(static_cast<int>(header));
     }
 
-    QRect GemRepoItemDelegate::CalcDeleteButtonRect(const QRect& contentRect) const
+    QRect GemRepoItemDelegate::CalcBadgeRect(const QRect& contentRect) const
     {
-        const int deleteHeaderEndX = CalcColumnXBounds(HeaderOrder::Delete).second;
-        const QPoint topLeft = QPoint(deleteHeaderEndX - s_iconSize - s_contentMargins.right(), contentRect.center().y() - s_iconSize / 2);
-        return QRect(topLeft, QSize(s_iconSize, s_iconSize));
+        const auto bounds = CalcColumnXBounds(HeaderOrder::Badge);
+        const QPoint topLeft = QPoint(bounds.first, contentRect.center().y() - s_badgeHeight / 2);
+        return QRect(topLeft, QSize(s_badgeWidth, s_badgeHeight));
     }
 
-    QRect GemRepoItemDelegate::CalcRefreshButtonRect(const QRect& contentRect) const
+    QRect GemRepoItemDelegate::CalcVisibilityButtonRect(const QRect& contentRect) const
     {
-        const int headerEndX = CalcColumnXBounds(HeaderOrder::Update).second;
-        const int leftX = headerEndX - s_refreshIconSize - s_refreshIconSpacing;
-        // Dividing size by 3 centers much better
-        const QPoint topLeft = QPoint(leftX, contentRect.center().y() - s_refreshIconSize / 3);
-        return QRect(topLeft, QSize(s_refreshIconSize, s_refreshIconSize));
+        const auto bounds = CalcColumnXBounds(HeaderOrder::Buttons);
+        const int centerX = (bounds.first + bounds.second) / 2;
+
+        const QPoint topLeft = QPoint(centerX + s_refreshIconSpacing, contentRect.center().y() - s_iconSize / 2);
+        return QRect(topLeft, QSize(s_iconSize, s_iconSize));
     }
 
-    void GemRepoItemDelegate::DrawEditButtons(QPainter* painter, const QRect& contentRect) const
+    QRect GemRepoItemDelegate::CalcRefreshButtonRect(const QRect& contentRect) const
     {
-        const QRect deleteButtonRect = CalcDeleteButtonRect(contentRect);
-        painter->drawPixmap(deleteButtonRect, m_deleteIcon);
+        const auto bounds = CalcColumnXBounds(HeaderOrder::Buttons);
+        const int centerX = (bounds.first + bounds.second) / 2;
+
+        const QPoint topLeft = QPoint(centerX - s_refreshIconSpacing - s_refreshIconSize, contentRect.center().y() - s_refreshIconSize / 2 + 1);
+        return QRect(topLeft, QSize(s_refreshIconSize, s_refreshIconSize));
     }
 
 } // namespace O3DE::ProjectManager

+ 20 - 9
Code/Tools/ProjectManager/Source/GemRepo/GemRepoItemDelegate.h

@@ -48,23 +48,30 @@ namespace O3DE::ProjectManager
         inline constexpr static QMargins s_contentMargins = QMargins(/*left=*/20, /*top=*/20, /*right=*/20, /*bottom=*/20); // Distances of the elements within an item to the item borders
         inline constexpr static int s_borderWidth = 4;
 
-        // Content
-        inline constexpr static int s_nameDefaultWidth = 150;
-        inline constexpr static int s_creatorDefaultWidth = 120;
+        // Content TableView is ~842px minimum
+        inline constexpr static int s_nameDefaultWidth = 200;
+        inline constexpr static int s_creatorDefaultWidth = 240;
+        inline constexpr static int s_badgeDefaultWidth = 150;
         inline constexpr static int s_updatedDefaultWidth = 130;
+        inline constexpr static int s_buttonsDefaultWidth = 80;
 
         // Icon
-        inline constexpr static int s_iconSize = 24;
+        inline constexpr static int s_iconSize = 20;
         inline constexpr static int s_iconSpacing = 16;
-        inline constexpr static int s_refreshIconSize = 14;
+        inline constexpr static int s_refreshIconSize = 16;
         inline constexpr static int s_refreshIconSpacing = 10;
 
+        inline constexpr static int s_badgeWidth = 130;
+        inline constexpr static int s_badgeHeight = 30;
+        inline constexpr static int s_badgeLeftMargin = 25;
+
         enum class HeaderOrder
         {
             Name,
             Creator,
-            Update,
-            Delete
+            Badge,
+            Updated,
+            Buttons 
         };
 
     signals:
@@ -75,9 +82,9 @@ namespace O3DE::ProjectManager
         void CalcRects(const QStyleOptionViewItem& option, QRect& outFullRect, QRect& outItemRect, QRect& outContentRect) const;
         QRect GetTextRect(QFont& font, const QString& text, qreal fontSize) const;
         QPair<int, int> CalcColumnXBounds(HeaderOrder header) const;
-        QRect CalcDeleteButtonRect(const QRect& contentRect) const;
+        QRect CalcBadgeRect(const QRect& contentRect) const;
+        QRect CalcVisibilityButtonRect(const QRect& contentRect) const;
         QRect CalcRefreshButtonRect(const QRect& contentRect) const;
-        void DrawEditButtons(QPainter* painter, const QRect& contentRect) const;
 
         QAbstractItemModel* m_model = nullptr;
 
@@ -86,5 +93,9 @@ namespace O3DE::ProjectManager
         QPixmap m_refreshIcon;
         QPixmap m_editIcon;
         QPixmap m_deleteIcon;
+        QPixmap m_hiddenIcon;
+        QPixmap m_visibleIcon;
+        QPixmap m_blueBadge;
+        QPixmap m_greenBadge;
     };
 } // namespace O3DE::ProjectManager

+ 143 - 29
Code/Tools/ProjectManager/Source/GemRepo/GemRepoModel.cpp

@@ -11,6 +11,7 @@
 
 #include <QItemSelectionModel>
 #include <QMessageBox>
+#include <QSortFilterProxyModel>
 
 namespace O3DE::ProjectManager
 {
@@ -18,7 +19,6 @@ namespace O3DE::ProjectManager
         : QStandardItemModel(parent)
     {
         m_selectionModel = new QItemSelectionModel(this, parent);
-        m_gemModel = new GemModel(this);
     }
 
     QItemSelectionModel* GemRepoModel::GetSelectionModel() const
@@ -26,6 +26,13 @@ namespace O3DE::ProjectManager
         return m_selectionModel;
     }
 
+    void SetItemDataSorted(QStandardItem* item, const QSet<QString>& stringSet, int role)
+    {
+        auto stringList = QStringList(stringSet.values());
+        stringList.sort(Qt::CaseInsensitive);
+        item->setData(stringList, role);
+    }
+
     void GemRepoModel::AddGemRepo(const GemRepoInfo& gemRepoInfo)
     {
         QStandardItem* item = new QStandardItem();
@@ -41,15 +48,75 @@ namespace O3DE::ProjectManager
         item->setData(gemRepoInfo.m_lastUpdated, RoleLastUpdated);
         item->setData(gemRepoInfo.m_path, RolePath);
         item->setData(gemRepoInfo.m_additionalInfo, RoleAdditionalInfo);
-        item->setData(gemRepoInfo.m_includedGemUris, RoleIncludedGems);
+        item->setData(static_cast<int>(gemRepoInfo.m_badgeType), RoleBadgeType);
 
         appendRow(item);
 
-        QVector<GemInfo> includedGemInfos = GetIncludedGemInfos(item->index());
-
-        for (const GemInfo& gemInfo : includedGemInfos)
+        if (!gemRepoInfo.m_repoUri.isEmpty())
         {
-            m_gemModel->AddGem(gemInfo);
+            // gems - including gems from deactivated repos
+            const auto& gemInfosResult = PythonBindingsInterface::Get()->GetGemInfosForRepo(gemRepoInfo.m_repoUri, /*enabledOnly*/false);
+            if (gemInfosResult.IsSuccess())
+            {
+                const QVector<GemInfo>& gemInfos = gemInfosResult.GetValue();
+                if (!gemInfos.isEmpty())
+                {
+                    // use a set to not include duplicate names because there are multiple versions of a gem
+                    QSet<QString> includedGems;
+                    for (const auto& gemInfo : gemInfos)
+                    {
+                        includedGems.insert(gemInfo.m_displayName.isEmpty() ? gemInfo.m_name : gemInfo.m_displayName);
+                    }
+                    SetItemDataSorted(item, includedGems, RoleIncludedGems);
+                }
+            }
+            else
+            {
+                QMessageBox::critical(nullptr, tr("Gems not found"), tr("Cannot find info for gems from repo %1").arg(gemRepoInfo.m_name));
+            }
+
+            // projects - including projects from deactivated repos
+            const auto& projectInfosResult = PythonBindingsInterface::Get()->GetProjectsForRepo(gemRepoInfo.m_repoUri, /*enabledOnly*/false);
+            if (projectInfosResult.IsSuccess())
+            {
+                const QVector<ProjectInfo>& projectInfos = projectInfosResult.GetValue();
+                if (!projectInfos.isEmpty())
+                {
+                    // use a set to not include duplicate names because there are multiple versions of a gem
+                    QSet<QString> includedProjects;
+                    for (const auto& projectInfo : projectInfos)
+                    {
+                        includedProjects.insert(projectInfo.m_displayName.isEmpty() ? projectInfo.m_projectName : projectInfo.m_displayName);
+                    }
+                    SetItemDataSorted(item, includedProjects, RoleIncludedProjects);
+                }
+            }
+            else
+            {
+                QMessageBox::critical(nullptr, tr("Projects not found"), tr("Cannot find info for projects from repo %1").arg(gemRepoInfo.m_name));
+            }
+
+            // project templates - including projects from deactivated repos
+            const auto& projectTemplateInfosResult =
+                PythonBindingsInterface::Get()->GetProjectTemplatesForRepo(gemRepoInfo.m_repoUri, /*enabledOnly*/false);
+            if (projectTemplateInfosResult.IsSuccess())
+            {
+                const QVector<ProjectTemplateInfo>& projectTemplateInfos = projectTemplateInfosResult.GetValue();
+                if (!projectTemplateInfos.isEmpty())
+                {
+                    // use a set to not include duplicate names because there are multiple versions of a gem
+                    QSet<QString> includedProjectTemplates;
+                    for (const auto& projectTemplateInfo : projectTemplateInfos)
+                    {
+                        includedProjectTemplates.insert(projectTemplateInfo.m_displayName.isEmpty() ? projectTemplateInfo.m_name : projectTemplateInfo.m_displayName);
+                    }
+                    SetItemDataSorted(item, includedProjectTemplates, RoleIncludedProjectTemplates);
+                }
+            }
+            else
+            {
+                QMessageBox::critical(nullptr, tr("Project templates not found"), tr("Cannot find info for project templates from repo %1").arg(gemRepoInfo.m_name));
+            }
         }
     }
 
@@ -98,41 +165,41 @@ namespace O3DE::ProjectManager
         return modelIndex.data(RolePath).toString();
     }
 
-    QStringList GemRepoModel::GetIncludedGemUris(const QModelIndex& modelIndex)
+    GemRepoInfo::BadgeType GemRepoModel::GetBadgeType(const QModelIndex& modelIndex)
     {
-        return modelIndex.data(RoleIncludedGems).toStringList();
+        return static_cast<GemRepoInfo::BadgeType>(modelIndex.data(RoleBadgeType).toInt());
     }
 
-    QVector<Tag> GemRepoModel::GetIncludedGemTags(const QModelIndex& modelIndex)
+    QVector<Tag> TagsFromStringList(const QStringList& stringList)
     {
+        if (stringList.isEmpty())
+        {
+            return {};
+        }
+
         QVector<Tag> tags;
-        const QVector<GemInfo>& gemInfos = GetIncludedGemInfos(modelIndex);
-        tags.reserve(gemInfos.size());
-        for (const GemInfo& gemInfo : gemInfos)
+        tags.reserve(stringList.size());
+        for (const QString& tagName : stringList)
         {
-            tags.append({ gemInfo.m_displayName, gemInfo.m_name });
+            tags.append({ tagName, tagName });
         }
 
         return tags;
     }
 
-    QVector<GemInfo> GemRepoModel::GetIncludedGemInfos(const QModelIndex& modelIndex)
+    QVector<Tag> GemRepoModel::GetIncludedGemTags(const QModelIndex& modelIndex)
     {
-        QString repoUri = GetRepoUri(modelIndex);
-        if (!repoUri.isEmpty())
-        {
-            const AZ::Outcome<QVector<GemInfo>, AZStd::string>& gemInfosResult = PythonBindingsInterface::Get()->GetGemInfosForRepo(repoUri);
-            if (gemInfosResult.IsSuccess())
-            {
-                return gemInfosResult.GetValue();
-            }
-            else
-            {
-                QMessageBox::critical(nullptr, tr("Gems not found"), tr("Cannot find info for gems from repo %1").arg(GetName(modelIndex)));
-            }
-        }
+        return TagsFromStringList(modelIndex.data(RoleIncludedGems).toStringList());
+    }
 
-        return QVector<GemInfo>();
+    QVector<Tag> GemRepoModel::GetIncludedProjectTags(const QModelIndex& modelIndex)
+    {
+        return TagsFromStringList(modelIndex.data(RoleIncludedProjects).toStringList());
+    }
+
+    QVector<Tag> GemRepoModel::GetIncludedProjectTemplateTags(const QModelIndex& modelIndex)
+    {
+        return TagsFromStringList(modelIndex.data(RoleIncludedProjectTemplates).toStringList());
     }
 
     bool GemRepoModel::IsEnabled(const QModelIndex& modelIndex)
@@ -142,7 +209,38 @@ namespace O3DE::ProjectManager
 
     void GemRepoModel::SetEnabled(QAbstractItemModel& model, const QModelIndex& modelIndex, bool isEnabled)
     {
-        model.setData(modelIndex, isEnabled, RoleIsEnabled);
+        QSortFilterProxyModel* proxyModel = qobject_cast<QSortFilterProxyModel*>(&model);
+        if (proxyModel)
+        {
+            GemRepoModel* repoModel = qobject_cast<GemRepoModel*>(proxyModel->sourceModel());
+            if (repoModel)
+            {
+                repoModel->SetRepoEnabled(proxyModel->mapToSource(modelIndex), isEnabled);
+            }
+        }
+    }
+
+    void GemRepoModel::SetRepoEnabled(const QModelIndex& modelIndex, bool isEnabled)
+    {
+        const QString repoUri = GetRepoUri(modelIndex);
+        const QString repoName = GetName(modelIndex);
+        if(PythonBindingsInterface::Get()->SetRepoEnabled(repoUri, isEnabled))
+        {
+            if (isEnabled)
+            {
+                emit ShowToastNotification(tr("%1 activated").arg(repoName));
+            }
+            else
+            {
+                emit ShowToastNotification(tr("%1 deactivated").arg(repoName));
+            }
+
+            setData(modelIndex, isEnabled, RoleIsEnabled);
+        }
+        else
+        {
+            QMessageBox::critical(nullptr, tr("Failed to change repo status"), tr("Failed to change the repo status for %1.  The local repo.json cache file could be corrupt or the repo.json was not downloaded").arg(repoName));
+        }
     }
 
     bool GemRepoModel::HasAdditionalInfo(const QModelIndex& modelIndex)
@@ -150,4 +248,20 @@ namespace O3DE::ProjectManager
         return !modelIndex.data(RoleAdditionalInfo).toString().isEmpty();
     }
 
+    QPersistentModelIndex GemRepoModel::FindModelIndexByRepoUri(const QString& repoUri)
+    {
+        // the number of repos should be small enough that we don't need a hash
+        for (int row = 0; row < rowCount(); ++row)
+        {
+            QModelIndex modelIndex = index(row, /*column*/ 0);
+            if (modelIndex.isValid() && modelIndex.data(RoleRepoUri).toString() == repoUri)
+            {
+                return modelIndex;
+            }
+        }
+
+        return {};
+    }
+
+
 } // namespace O3DE::ProjectManager

+ 14 - 6
Code/Tools/ProjectManager/Source/GemRepo/GemRepoModel.h

@@ -11,7 +11,7 @@
 #if !defined(Q_MOC_RUN)
 #include <QStandardItemModel>
 #include <GemRepo/GemRepoInfo.h>
-#include <GemCatalog/GemModel.h>
+#include <TagWidget.h>
 #endif
 
 QT_FORWARD_DECLARE_CLASS(QItemSelectionModel)
@@ -38,16 +38,16 @@ namespace O3DE::ProjectManager
         static QString GetRepoUri(const QModelIndex& modelIndex);
         static QDateTime GetLastUpdated(const QModelIndex& modelIndex);
         static QString GetPath(const QModelIndex& modelIndex);
+        static GemRepoInfo::BadgeType GetBadgeType(const QModelIndex& modelIndex);
 
-        static QStringList GetIncludedGemUris(const QModelIndex& modelIndex);
         static QVector<Tag> GetIncludedGemTags(const QModelIndex& modelIndex);
-        static QVector<GemInfo> GetIncludedGemInfos(const QModelIndex& modelIndex);
+        static QVector<Tag> GetIncludedProjectTags(const QModelIndex& modelIndex);
+        static QVector<Tag> GetIncludedProjectTemplateTags(const QModelIndex& modelIndex);
 
         static bool IsEnabled(const QModelIndex& modelIndex);
         static void SetEnabled(QAbstractItemModel& model, const QModelIndex& modelIndex, bool isEnabled);
         static bool HasAdditionalInfo(const QModelIndex& modelIndex);
 
-    private:
         enum UserRole
         {
             RoleName = Qt::UserRole,
@@ -60,10 +60,18 @@ namespace O3DE::ProjectManager
             RolePath,
             RoleAdditionalInfo,
             RoleIncludedGems,
+            RoleIncludedProjects,
+            RoleIncludedProjectTemplates,
+            RoleBadgeType
         };
 
-        QItemSelectionModel* m_selectionModel = nullptr;
+        void SetRepoEnabled(const QModelIndex& modelIndex, bool isEnabled);
+        QPersistentModelIndex FindModelIndexByRepoUri(const QString& repoUri);
+
+    signals:
+        void ShowToastNotification(const QString& notification);
 
-        GemModel* m_gemModel = nullptr;
+    private:
+        QItemSelectionModel* m_selectionModel = nullptr;
     };
 } // namespace O3DE::ProjectManager

+ 44 - 0
Code/Tools/ProjectManager/Source/GemRepo/GemRepoProxyModel.cpp

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <GemRepo/GemRepoProxyModel.h>
+#include <GemRepo/GemRepoModel.h>
+#include <ProjectManagerDefs.h>
+
+namespace O3DE::ProjectManager
+{
+    GemRepoProxyModel::GemRepoProxyModel(QObject* parent)
+        : QSortFilterProxyModel(parent)
+    {
+    }
+
+    bool GemRepoProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const
+    {
+        GemRepoModel* model = qobject_cast<GemRepoModel*>(sourceModel());
+        if (model)
+        {
+            const QString leftUri = model->GetRepoUri(left);
+            if (leftUri.compare(CanonicalRepoUri, Qt::CaseInsensitive) == 0)
+            {
+                // make sure canonical is at top
+                return true;
+            }
+
+            const QString rightUri = model->GetRepoUri(right);
+            if (rightUri.compare(CanonicalRepoUri, Qt::CaseInsensitive) == 0)
+            {
+                // make sure canonical is at top
+                return false;
+            }
+
+            return model->GetName(left).compare(model->GetName(right), Qt::CaseInsensitive) < 0;
+        }
+        return true;
+    }
+
+} // namespace O3DE::ProjectManager

+ 27 - 0
Code/Tools/ProjectManager/Source/GemRepo/GemRepoProxyModel.h

@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+
+#if !defined(Q_MOC_RUN)
+#include <QSortFilterProxyModel>
+#endif
+
+namespace O3DE::ProjectManager
+{
+    class GemRepoProxyModel
+        : public QSortFilterProxyModel
+    {
+        Q_OBJECT
+    public:
+        explicit GemRepoProxyModel(QObject* parent = nullptr);
+
+    protected:
+        bool lessThan(const QModelIndex& left, const QModelIndex& right) const override;
+    };
+} // namespace O3DE::ProjectManager

+ 137 - 32
Code/Tools/ProjectManager/Source/GemRepo/GemRepoScreen.cpp

@@ -12,6 +12,7 @@
 #include <GemRepo/GemRepoModel.h>
 #include <GemRepo/GemRepoAddDialog.h>
 #include <GemRepo/GemRepoInspector.h>
+#include <GemRepo/GemRepoProxyModel.h>
 #include <PythonBindingsInterface.h>
 #include <ProjectManagerDefs.h>
 #include <ProjectUtils.h>
@@ -29,6 +30,7 @@
 #include <QFrame>
 #include <QStackedWidget>
 #include <QMessageBox>
+#include <QItemSelectionModel>
 
 namespace O3DE::ProjectManager
 {
@@ -36,6 +38,8 @@ namespace O3DE::ProjectManager
         : ScreenWidget(parent)
     {
         m_gemRepoModel = new GemRepoModel(this);
+        m_gemRepoModel->setSortRole(GemRepoModel::UserRole::RoleName);
+        connect(m_gemRepoModel, &GemRepoModel::ShowToastNotification, this, &GemRepoScreen::ShowStandardToastNotification);
 
         QVBoxLayout* vLayout = new QVBoxLayout();
         vLayout->setMargin(0);
@@ -52,34 +56,70 @@ namespace O3DE::ProjectManager
 
         vLayout->addWidget(m_contentStack);
 
-        Reinit();
+        m_notificationsView = AZStd::make_unique<AzToolsFramework::ToastNotificationsView>(this, AZ_CRC_CE("ReposNotificationsView"));
+        m_notificationsView->SetOffset(QPoint(10, 10));
+        m_notificationsView->SetMaxQueuedNotifications(1);
+        m_notificationsView->SetRejectDuplicates(false); // we want to show notifications if a user repeats actions
+
+        ScreensCtrl* screensCtrl = GetScreensCtrl(this);
+        if (screensCtrl)
+        {
+            connect(this, &GemRepoScreen::NotifyRemoteContentRefreshed, screensCtrl, &ScreensCtrl::NotifyRemoteContentRefreshed);
+        }
     }
 
     void GemRepoScreen::NotifyCurrentScreen()
     {
+        constexpr bool downloadMissingOnly = true;
+        PythonBindingsInterface::Get()->RefreshAllGemRepos(downloadMissingOnly);
         Reinit();
+
+        // we might have downloading missing data so make sure to update the GemCatalog
+        emit NotifyRemoteContentRefreshed();
     }
 
     void GemRepoScreen::Reinit()
     {
+        QString selectedRepoUri;
+        QPersistentModelIndex selectedIndex = m_selectionModel->currentIndex();
+        if (selectedIndex.isValid())
+        {
+            selectedIndex = m_sortProxyModel->mapToSource(selectedIndex);
+            selectedRepoUri = GemRepoModel::GetRepoUri(selectedIndex);
+        }
+
+        disconnect(m_gemRepoModel, &GemRepoModel::dataChanged, this, &GemRepoScreen::OnModelDataChanged);
+
         m_gemRepoModel->clear();
         FillModel();
 
+        connect(m_gemRepoModel, &GemRepoModel::dataChanged, this, &GemRepoScreen::OnModelDataChanged);
+
         // If model contains any data show the repos
         if (m_gemRepoModel->rowCount())
         {
             m_contentStack->setCurrentWidget(m_repoContent);
+
+            QPersistentModelIndex modelIndex;
+            if (!selectedRepoUri.isEmpty())
+            {
+                // attempt to re-select the row with the unique RepoURI if it still exists
+                modelIndex = m_gemRepoModel->FindModelIndexByRepoUri(selectedRepoUri);
+                modelIndex = m_sortProxyModel->mapFromSource(modelIndex);
+            }
+
+            if (!modelIndex.isValid())
+            {
+                // fallback to selecting the first item in the list
+                modelIndex = m_sortProxyModel->index(0, 0);
+            }
+
+            m_gemRepoListView->selectionModel()->setCurrentIndex(modelIndex, QItemSelectionModel::ClearAndSelect);
         }
         else
         {
             m_contentStack->setCurrentWidget(m_noRepoContent);
         }
-
-        // Select the first entry after everything got correctly sized
-        QTimer::singleShot(200, [=]{
-            QModelIndex firstModelIndex = m_gemRepoListView->model()->index(0,0);
-            m_gemRepoListView->selectionModel()->setCurrentIndex(firstModelIndex, QItemSelectionModel::ClearAndSelect);
-        });
     }
 
     void GemRepoScreen::HandleAddRepoButton()
@@ -98,8 +138,10 @@ namespace O3DE::ProjectManager
             auto addGemRepoResult = PythonBindingsInterface::Get()->AddGemRepo(repoUri);
             if (addGemRepoResult.IsSuccess())
             {
+                ShowStandardToastNotification(tr("Repo added successfully!"));
+
                 Reinit();
-                emit OnRefresh();
+                emit NotifyRemoteContentRefreshed();
             }
             else
             {
@@ -124,8 +166,10 @@ namespace O3DE::ProjectManager
             bool removeGemRepoResult = PythonBindingsInterface::Get()->RemoveGemRepo(repoUri);
             if (removeGemRepoResult)
             {
+                ShowStandardToastNotification(tr("Repo removed"));
+
                 Reinit();
-                emit OnRefresh();
+                emit NotifyRemoteContentRefreshed();
             }
             else
             {
@@ -138,11 +182,17 @@ namespace O3DE::ProjectManager
 
     void GemRepoScreen::HandleRefreshAllButton()
     {
-        bool refreshResult = PythonBindingsInterface::Get()->RefreshAllGemRepos();
+        // re-download everything when the user presses the refresh all button
+        constexpr bool downloadMissingOnly = false;
+        bool refreshResult = PythonBindingsInterface::Get()->RefreshAllGemRepos(downloadMissingOnly);
         Reinit();
-        emit OnRefresh();
+        emit NotifyRemoteContentRefreshed();
 
-        if (!refreshResult)
+        if (refreshResult)
+        {
+            ShowStandardToastNotification(tr("Repos updated"));
+        }
+        else
         {
             QMessageBox::critical(
                 this, tr("Operation failed"), QString("Some repos failed to refresh."));
@@ -152,12 +202,17 @@ namespace O3DE::ProjectManager
     void GemRepoScreen::HandleRefreshRepoButton(const QModelIndex& modelIndex)
     {
         const QString repoUri = m_gemRepoModel->GetRepoUri(modelIndex);
+        const QString repoName = m_gemRepoModel->GetName(modelIndex);
 
-        AZ::Outcome<void, AZStd::string> refreshResult = PythonBindingsInterface::Get()->RefreshGemRepo(repoUri);
+        // re-download everything when the user presses the refresh all button
+        constexpr bool downloadMissingOnly = false;
+        AZ::Outcome<void, AZStd::string> refreshResult = PythonBindingsInterface::Get()->RefreshGemRepo(repoUri, downloadMissingOnly);
         if (refreshResult.IsSuccess())
         {
             Reinit();
-            emit OnRefresh();
+            emit NotifyRemoteContentRefreshed();
+
+            ShowStandardToastNotification(tr("%1 updated").arg(repoName));
         }
         else
         {
@@ -193,12 +248,22 @@ namespace O3DE::ProjectManager
 
             if (!allGemRepoInfos.isEmpty())
             {
-                m_lastAllUpdateLabel->setText(tr("Last Updated: %1").arg(oldestRepoUpdate.toString(RepoTimeFormat)));
+                // get the month day and year in the preferred locale's format (QLocale defaults to the OS locale)
+                QString monthDayYear = oldestRepoUpdate.toString(QLocale().dateFormat(QLocale::ShortFormat));
+
+                // always show 12 hour + minutes + am/pm
+                QString hourMinuteAMPM = oldestRepoUpdate.toString("h:mmap");
+
+                QString repoUpdatedDate = QString("%1 %2").arg(monthDayYear, hourMinuteAMPM);
+
+                m_lastAllUpdateLabel->setText(tr("Last Updated: %1").arg(repoUpdatedDate));
             }
             else
             {
                 m_lastAllUpdateLabel->setText(tr("Last Updated: Never"));
             }
+
+            m_sortProxyModel->sort(/*column*/0);
         }
         else
         {
@@ -206,6 +271,30 @@ namespace O3DE::ProjectManager
         }
     }
 
+    void GemRepoScreen::OnModelDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QVector<int>& roles)
+    {
+        if (roles.isEmpty() || roles.at(0) == GemRepoModel::UserRole::RoleIsEnabled)
+        {
+            QItemSelection updatedItems(topLeft, bottomRight);
+            for (const QModelIndex& modelIndex : updatedItems.indexes())
+            {
+                const bool isEnabled = GemRepoModel::IsEnabled(modelIndex);
+                QString repoUri = GemRepoModel::GetRepoUri(modelIndex);
+                PythonBindingsInterface::Get()->SetRepoEnabled(repoUri, isEnabled);
+
+                const QString repoName = m_gemRepoModel->GetName(modelIndex);
+                if (isEnabled)
+                {
+                    ShowStandardToastNotification(tr("%1 activated").arg(repoName));
+                }
+                else
+                {
+                    ShowStandardToastNotification(tr("%1 deactivated").arg(repoName));
+                }
+            }
+        }
+    }
+
     QFrame* GemRepoScreen::CreateNoReposContent()
     {
         QFrame* contentFrame = new QFrame(this);
@@ -277,15 +366,15 @@ namespace O3DE::ProjectManager
         m_lastAllUpdateLabel->setObjectName("gemRepoHeaderLabel");
         topMiddleHLayout->addWidget(m_lastAllUpdateLabel);
 
-        topMiddleHLayout->addSpacing(20);
-
-        m_AllUpdateButton = new QPushButton(QIcon(":/Refresh.svg"), tr("Update All"), this);
-        m_AllUpdateButton->setObjectName("gemRepoHeaderRefreshButton");
-        topMiddleHLayout->addWidget(m_AllUpdateButton);
+        topMiddleHLayout->addSpacerItem(new QSpacerItem(0, 0, QSizePolicy::Expanding, QSizePolicy::Minimum));
 
-        connect(m_AllUpdateButton, &QPushButton::clicked, this, &GemRepoScreen::HandleRefreshAllButton);
+        QPushButton* updateAllButton = new QPushButton(QIcon(":/Refresh.svg").pixmap(16, 16), tr("Update All"), this);
+        updateAllButton->setObjectName("gemRepoAddButton");
+        updateAllButton->setProperty("secondary", true);
+        topMiddleHLayout->addWidget(updateAllButton);
+        connect(updateAllButton, &QPushButton::clicked, this, &GemRepoScreen::HandleRefreshAllButton);
 
-        topMiddleHLayout->addSpacerItem(new QSpacerItem(0, 0, QSizePolicy::Expanding, QSizePolicy::Minimum));
+        topMiddleHLayout->addSpacing(10);
 
         QPushButton* addRepoButton = new QPushButton(tr("Add Repository"), this);
         addRepoButton->setObjectName("gemRepoAddButton");
@@ -298,16 +387,17 @@ namespace O3DE::ProjectManager
 
         middleVLayout->addSpacing(30);
 
-        constexpr int minHeaderSectionWidth = 120;
+        constexpr int minHeaderSectionWidth = 80;
 
         m_gemRepoHeaderTable = new AdjustableHeaderWidget(
-            QStringList{ tr("Repository Name"), tr("Creator"), tr("Updated"), "" },
+            QStringList{ tr("Repository Name"), tr("Creator"), "", tr("Updated Date"), tr("Status") },
             QVector<int>{
-                GemRepoItemDelegate::s_nameDefaultWidth,
+                GemRepoItemDelegate::s_nameDefaultWidth + GemRepoItemDelegate::s_contentMargins.left(),
                 GemRepoItemDelegate::s_creatorDefaultWidth,
-                GemRepoItemDelegate::s_updatedDefaultWidth + GemRepoItemDelegate::s_refreshIconSpacing + GemRepoItemDelegate::s_refreshIconSize,
+                GemRepoItemDelegate::s_badgeDefaultWidth,
+                GemRepoItemDelegate::s_updatedDefaultWidth,
                 // Include invisible header for delete button 
-                GemRepoItemDelegate::s_iconSize + GemRepoItemDelegate::s_contentMargins.right()
+                GemRepoItemDelegate::s_buttonsDefaultWidth + GemRepoItemDelegate::s_contentMargins.right()
             },
             minHeaderSectionWidth,
             QVector<QHeaderView::ResizeMode>
@@ -315,29 +405,44 @@ namespace O3DE::ProjectManager
                 QHeaderView::ResizeMode::Interactive,
                 QHeaderView::ResizeMode::Stretch,
                 QHeaderView::ResizeMode::Fixed,
+                QHeaderView::ResizeMode::Fixed,
                 QHeaderView::ResizeMode::Fixed
             },
             this);
 
         middleVLayout->addWidget(m_gemRepoHeaderTable);
 
-        m_gemRepoListView = new GemRepoListView(m_gemRepoModel, m_gemRepoModel->GetSelectionModel(), m_gemRepoHeaderTable, this);
-        middleVLayout->addWidget(m_gemRepoListView);
+        m_sortProxyModel = new GemRepoProxyModel(this);
+        m_sortProxyModel->setSourceModel(m_gemRepoModel);
+        m_sortProxyModel->setSortCaseSensitivity(Qt::CaseInsensitive);
+        m_sortProxyModel->setSortRole(GemRepoModel::UserRole::RoleName);
 
-        connect(m_gemRepoListView, &GemRepoListView::RemoveRepo, this, &GemRepoScreen::HandleRemoveRepoButton);
+        m_selectionModel = new QItemSelectionModel(m_sortProxyModel, this);
+        m_gemRepoListView = new GemRepoListView(m_sortProxyModel, m_selectionModel, m_gemRepoHeaderTable, this);
         connect(m_gemRepoListView, &GemRepoListView::RefreshRepo, this, &GemRepoScreen::HandleRefreshRepoButton);
+        middleVLayout->addWidget(m_gemRepoListView);
 
         hLayout->addLayout(middleVLayout);
-
         hLayout->addSpacing(middleLayoutIndent);
 
-        m_gemRepoInspector = new GemRepoInspector(m_gemRepoModel, this);
+        m_gemRepoInspector = new GemRepoInspector(m_gemRepoModel, m_selectionModel, this);
+        connect(m_gemRepoInspector, &GemRepoInspector::RemoveRepo, this, &GemRepoScreen::HandleRemoveRepoButton);
+        connect(m_gemRepoInspector, &GemRepoInspector::ShowToastNotification, this, &GemRepoScreen::ShowStandardToastNotification);
         m_gemRepoInspector->setFixedWidth(inspectorWidth);
         hLayout->addWidget(m_gemRepoInspector);
 
         return contentFrame;
     }
 
+    void GemRepoScreen::ShowStandardToastNotification(const QString& notification)
+    {
+        AzQtComponents::ToastConfiguration toastConfiguration(AzQtComponents::ToastType::Custom, notification, "");
+        toastConfiguration.m_customIconImage = ":/Info.svg";
+        toastConfiguration.m_borderRadius = 4;
+        toastConfiguration.m_duration = AZStd::chrono::milliseconds(3000);
+        m_notificationsView->ShowToastNotification(toastConfiguration);
+    }
+
     ProjectManagerScreen GemRepoScreen::GetScreenEnum()
     {
         return ProjectManagerScreen::GemRepos;

+ 11 - 5
Code/Tools/ProjectManager/Source/GemRepo/GemRepoScreen.h

@@ -10,6 +10,7 @@
 
 #if !defined(Q_MOC_RUN)
 #include <ScreenWidget.h>
+#include <AzToolsFramework/UI/Notifications/ToastNotificationsView.h>
 #endif
 
 QT_FORWARD_DECLARE_CLASS(QLabel)
@@ -18,12 +19,15 @@ QT_FORWARD_DECLARE_CLASS(QHeaderView)
 QT_FORWARD_DECLARE_CLASS(QTableWidget)
 QT_FORWARD_DECLARE_CLASS(QFrame)
 QT_FORWARD_DECLARE_CLASS(QStackedWidget)
+QT_FORWARD_DECLARE_CLASS(QSortFilterProxyModel)
+QT_FORWARD_DECLARE_CLASS(QItemSelectionModel)
 
 namespace O3DE::ProjectManager
 {
     QT_FORWARD_DECLARE_CLASS(GemRepoInspector)
     QT_FORWARD_DECLARE_CLASS(GemRepoListView)
     QT_FORWARD_DECLARE_CLASS(GemRepoModel)
+    QT_FORWARD_DECLARE_CLASS(GemRepoProxyModel)
     QT_FORWARD_DECLARE_CLASS(AdjustableHeaderWidget)
 
     class GemRepoScreen
@@ -41,18 +45,19 @@ namespace O3DE::ProjectManager
 
         void NotifyCurrentScreen() override;
 
-    signals:
-        void OnRefresh();
-
     public slots:
+        void ShowStandardToastNotification(const QString& notification);
         void HandleAddRepoButton();
         void HandleRemoveRepoButton(const QModelIndex& modelIndex);
         void HandleRefreshAllButton();
         void HandleRefreshRepoButton(const QModelIndex& modelIndex);
-
+        void OnModelDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QVector<int>& roles);
 
     private:
         void FillModel();
+
+        AZStd::unique_ptr<AzToolsFramework::ToastNotificationsView> m_notificationsView;
+
         QFrame* CreateNoReposContent();
         QFrame* CreateReposContent();
 
@@ -65,8 +70,9 @@ namespace O3DE::ProjectManager
         GemRepoListView* m_gemRepoListView = nullptr;
         GemRepoInspector* m_gemRepoInspector = nullptr;
         GemRepoModel* m_gemRepoModel = nullptr;
+        GemRepoProxyModel* m_sortProxyModel = nullptr;
+        QItemSelectionModel* m_selectionModel = nullptr;
 
         QLabel* m_lastAllUpdateLabel;
-        QPushButton* m_AllUpdateButton;
     };
 } // namespace O3DE::ProjectManager

+ 4 - 0
Code/Tools/ProjectManager/Source/GemsSubWidget.cpp

@@ -40,7 +40,11 @@ namespace O3DE::ProjectManager
     void GemsSubWidget::Update(const QString& title, const QString& text, const QVector<Tag>& tags)
     {
         m_titleLabel->setText(title);
+
+        // hide the text label if it's empty
         m_textLabel->setText(text);
+        m_textLabel->setVisible(!text.isEmpty());
+
         m_tagWidget->Update(tags);
         m_tagWidget->adjustSize();
         adjustSize();

+ 7 - 2
Code/Tools/ProjectManager/Source/LinkWidget.cpp

@@ -18,7 +18,7 @@
 namespace O3DE::ProjectManager
 {
     LinkLabel::LinkLabel(const QString& text, const QUrl& url, int fontSize, QWidget* parent)
-        : QLabel(text, parent)
+        : AzQtComponents::ElidingLabel(text, parent)
         , m_url(url)
         , m_fontSize(fontSize)
     {
@@ -36,7 +36,7 @@ namespace O3DE::ProjectManager
             if (!skipDialog)
             {
                 // Style does not apply if LinkLabel is parent so use parentWidget as parent instead
-                ExternalLinkDialog* linkDialog = new ExternalLinkDialog(m_url.toString(), parentWidget());
+                ExternalLinkDialog* linkDialog = new ExternalLinkDialog(m_url, parentWidget());
                 if (linkDialog->exec() == QDialog::Accepted)
                 {
                     QDesktopServices::openUrl(m_url);
@@ -61,6 +61,11 @@ namespace O3DE::ProjectManager
         SetDefaultStyle();
     }
 
+    QUrl LinkLabel::GetUrl() const
+    {
+        return m_url;
+    }
+
     void LinkLabel::SetUrl(const QUrl& url)
     {
         m_url = url;

+ 3 - 1
Code/Tools/ProjectManager/Source/LinkWidget.h

@@ -9,6 +9,7 @@
 #pragma once
 
 #if !defined(Q_MOC_RUN)
+#include <AzQtComponents/Components/Widgets/ElidingLabel.h>
 #include <QLabel>
 #include <QUrl>
 #endif
@@ -20,13 +21,14 @@ QT_FORWARD_DECLARE_CLASS(QWidget)
 namespace O3DE::ProjectManager
 {
     class LinkLabel
-        : public QLabel
+        : public AzQtComponents::ElidingLabel
     {
         Q_OBJECT
 
     public:
         LinkLabel(const QString& text = {}, const QUrl& url = {}, int fontSize = 10, QWidget* parent = nullptr);
 
+        QUrl GetUrl() const;
         void SetUrl(const QUrl& url);
 
     signals:

+ 1 - 0
Code/Tools/ProjectManager/Source/NewProjectSettingsScreen.cpp

@@ -428,6 +428,7 @@ namespace O3DE::ProjectManager
         ProjectTemplateInfo resolvedTemplateInfo = templateInfo.IsValid() ? templateInfo : GetSelectedProjectTemplateInfo();
         if (!resolvedTemplateInfo.IsValid())
         {
+            QMessageBox::critical(this, tr("Failed to find project template"), tr("The remote project template info for %1 could not be found or is invalid.\n\nPlease try refreshing the remote repository it came from, or download the template and register it through the o3de CLI.").arg(templateInfo.m_name));
             return;
         }
 

+ 4 - 1
Code/Tools/ProjectManager/Source/ProjectInfo.h

@@ -57,7 +57,10 @@ namespace O3DE::ProjectManager
         QString m_license;
         QStringList m_userTags;
 
-        //! Used as temp variable for replace images
+        QStringList m_requiredGemDependencies;
+        QStringList m_optionalGemDependencies;
+
+        // Used as temp variable for replace images
         QString m_newPreviewImagePath;
         QString m_newBackgroundImagePath;
 

+ 2 - 1
Code/Tools/ProjectManager/Source/ProjectManagerDefs.h

@@ -30,6 +30,7 @@ namespace O3DE::ProjectManager
     static const QString ProjectCMakeCommand = "cmake";
     static const QString ProjectCMakeBuildTargetEditor = "Editor";
 
-    static const QString RepoTimeFormat = "dd/MM/yyyy hh:mmap";
+    static const QString RepoTimeFormat = "MM/dd/yyyy hh:mmap";
+    static const QString CanonicalRepoUri = "https://canonical.o3de.org";
 
 } // namespace O3DE::ProjectManager

+ 3 - 2
Code/Tools/ProjectManager/Source/ProjectManagerWindow.cpp

@@ -48,12 +48,13 @@ namespace O3DE::ProjectManager
 
         setCentralWidget(screensCtrl);
 
-        // always push the projects screen first so we have something to come back to
+        // Projects is the default first screen because it is first in the above order
         if (startScreen != ProjectManagerScreen::Projects)
         {
+            // always push the projects screen first so we have something to come back to
             screensCtrl->ForceChangeToScreen(ProjectManagerScreen::Projects);
+            screensCtrl->ForceChangeToScreen(startScreen);
         }
-        screensCtrl->ForceChangeToScreen(startScreen);
 
         if (!projectPath.empty())
         {

+ 47 - 2
Code/Tools/ProjectManager/Source/ProjectUtils.cpp

@@ -289,11 +289,56 @@ namespace O3DE::ProjectManager
 
         bool RegisterProject(const QString& path, QWidget* parent)
         {
-            if (auto result = PythonBindingsInterface::Get()->AddProject(path); !result)
+            auto incompatibleObjectsResult = PythonBindingsInterface::Get()->GetProjectEngineIncompatibleObjects(path);
+
+            AZStd::string errorTitle, generalError, detailedError;
+            if (!incompatibleObjectsResult)
+            {
+                errorTitle = "Failed to check project compatibility";
+                generalError = incompatibleObjectsResult.GetError().first;
+                generalError.append("\nDo you still want to add this project?");
+                detailedError = incompatibleObjectsResult.GetError().second;
+            }
+            else if (const auto& incompatibleObjects = incompatibleObjectsResult.GetValue(); !incompatibleObjects.isEmpty())
+            {
+                // provide a couple more user friendly error messages for uncommon cases
+                if (incompatibleObjects.at(0).contains("engine.json", Qt::CaseInsensitive))
+                {
+                    errorTitle = "Failed to read engine.json";
+                    generalError = "The projects compatibility with this engine could not be checked because the engine.json could not be read";
+                }
+                else if (incompatibleObjects.at(0).contains("project.json", Qt::CaseInsensitive))
+                {
+                    errorTitle = "Invalid project, failed to read project.json";
+                    generalError = "The projects compatibility with this engine could not be checked because the project.json could not be read.";
+                }
+                else
+                {
+                    // could be gems, apis or both
+                    errorTitle = "Project may not be compatible with this engine";
+                    generalError = incompatibleObjects.join("\n").toUtf8().constData();
+                    generalError.append("\nDo you still want to add this project?");
+                }
+            }
+
+            if (!generalError.empty())
+            {
+                QMessageBox warningDialog(QMessageBox::Warning, errorTitle.c_str(), generalError.c_str(), QMessageBox::Yes | QMessageBox::No, parent);
+                warningDialog.setDetailedText(detailedError.c_str());
+                if(warningDialog.exec() == QMessageBox::No)
+                {
+                    return false;
+                }
+                AZ_Warning("ProjectManager", false, "Proceeding with project registration after compatibility check failed.");
+            }
+
+            if (auto addProjectResult = PythonBindingsInterface::Get()->AddProject(path, /*force=*/true); !addProjectResult)
             {
-                DisplayDetailedError("Failed to add project", result, parent);
+                DisplayDetailedError(QObject::tr("Failed to add project"), addProjectResult, parent);
+                AZ_Error("ProjectManager", false, "Failed to register project at path '%s'", path.toUtf8().constData());
                 return false;
             }
+
             return true;
         }
 

+ 163 - 189
Code/Tools/ProjectManager/Source/ProjectsScreen.cpp

@@ -173,8 +173,6 @@ namespace O3DE::ProjectManager
             projectsScrollArea->setWidget(scrollWidget);
             projectsScrollArea->setWidgetResizable(true);
 
-            ResetProjectsContent();
-
             layout->addWidget(projectsScrollArea);
         }
 
@@ -207,109 +205,51 @@ namespace O3DE::ProjectManager
         return projectButton;
     }
 
-    void ProjectsScreen::ResetProjectsContent()
+    void ProjectsScreen::RemoveProjectButtonsFromFlowLayout(const QVector<ProjectInfo>& projectsToKeep)
     {
-        RemoveInvalidProjects();
-
-        // Get all projects and sort so that building and queued projects appear first
-        // followed by the remaining projects in alphabetical order
-        QVector<ProjectInfo> projects;
-        auto projectsResult = PythonBindingsInterface::Get()->GetProjects();
-        if (projectsResult.IsSuccess() && !projectsResult.GetValue().isEmpty())
+        // If a project path is in this set then the button for it will be kept
+        AZStd::unordered_set<AZ::IO::Path> keepProject;
+        for (const ProjectInfo& project : projectsToKeep)
         {
-            projects.append(projectsResult.GetValue());
+            keepProject.insert(project.m_path.toUtf8().constData());
         }
 
-        // Also add remote projects that we do not have a local copy of
-        auto remoteProjectsResult = PythonBindingsInterface::Get()->GetProjectsForAllRepos();
-        if (remoteProjectsResult.IsSuccess() && !remoteProjectsResult.GetValue().isEmpty())
+        // Remove buttons from flow layout and delete buttons for removed projects 
+        auto projectButtonsIter = m_projectButtons.begin();
+        while (projectButtonsIter != m_projectButtons.end())
         {
-            const QVector<ProjectInfo>& remoteProjects{ remoteProjectsResult.TakeValue() };
-            for (const ProjectInfo& remoteProject : remoteProjects)
-            {
-                auto foundProject = AZStd::ranges::find_if(
-                    projects,
-                    [&remoteProject](const ProjectInfo& value)
-                    {
-                        return remoteProject.m_id == value.m_id;
-                    });
-                if (foundProject == projects.end())
-                {
-                    projects.append(remoteProject);
-                }
-            }
-        }
+            const auto button = projectButtonsIter->second;
+            m_projectsFlowLayout->removeWidget(button);
 
-        if (!projects.isEmpty())
-        {
-            // If a project path is in this set then the button for it will be kept
-            AZStd::unordered_set<AZ::IO::Path> keepProject;
-            for (const ProjectInfo& project : projects)
+            if (!keepProject.contains(projectButtonsIter->first))
             {
-                keepProject.insert(project.m_path.toUtf8().constData());
-            }
-
-            // Remove buttons from flow layout and delete buttons for removed projects 
-            auto projectButtonsIter = m_projectButtons.begin();
-            while (projectButtonsIter != m_projectButtons.end())
-            {
-                const auto button = projectButtonsIter->second;
-                m_projectsFlowLayout->removeWidget(button);
-
-                if (!keepProject.contains(projectButtonsIter->first))
-                {
-                    m_fileSystemWatcher->removePath(QDir::toNativeSeparators(button->GetProjectInfo().m_path + "/project.json"));
-                    button->deleteLater();
-                    projectButtonsIter = m_projectButtons.erase(projectButtonsIter);
-                }
-                else
-                {
-                    ++projectButtonsIter;
-                }
+                m_fileSystemWatcher->removePath(QDir::toNativeSeparators(button->GetProjectInfo().m_path + "/project.json"));
+                button->deleteLater();
+                projectButtonsIter = m_projectButtons.erase(projectButtonsIter);
             }
-
-            AZ::IO::Path buildProjectPath;
-            if (m_currentBuilder)
+            else
             {
-                buildProjectPath = AZ::IO::Path(m_currentBuilder->GetProjectInfo().m_path.toUtf8().constData());
+                ++projectButtonsIter;
             }
+        }
+    }
 
-            // Put currently building project in front, then queued projects, then sorts alphabetically
-            AZStd::sort(projects.begin(), projects.end(), [buildProjectPath, this](const ProjectInfo& arg1, const ProjectInfo& arg2)
-            {
-                if (!buildProjectPath.empty())
-                {
-                    if (AZ::IO::Path(arg1.m_path.toUtf8().constData()) == buildProjectPath)
-                    {
-                        return true;
-                    }
-                    else if (AZ::IO::Path(arg2.m_path.toUtf8().constData()) == buildProjectPath)
-                    {
-                        return false;
-                    }
-                }
+    void ProjectsScreen::UpdateIfCurrentScreen()
+    {
+        if (IsCurrentScreen())
+        {
+            UpdateWithProjects(GetAllProjects());
+        }
+    }
 
-                bool arg1InBuildQueue = BuildQueueContainsProject(arg1.m_path);
-                bool arg2InBuildQueue = BuildQueueContainsProject(arg2.m_path);
-                if (arg1InBuildQueue && !arg2InBuildQueue)
-                {
-                    return true;
-                }
-                else if (!arg1InBuildQueue && arg2InBuildQueue)
-                {
-                    return false;
-                }
-                else if (arg1.m_displayName.compare(arg2.m_displayName, Qt::CaseInsensitive) == 0)
-                {
-                    // handle case where names are the same
-                    return arg1.m_path.toLower() < arg2.m_path.toLower();
-                }
-                else
-                {
-                    return arg1.m_displayName.toLower() < arg2.m_displayName.toLower();
-                }
-            });
+    void ProjectsScreen::UpdateWithProjects(const QVector<ProjectInfo>& projects)
+    {
+        PythonBindingsInterface::Get()->RemoveInvalidProjects();
 
+        if (!projects.isEmpty())
+        {
+            // Remove all existing buttons before adding them back in the correct order
+            RemoveProjectButtonsFromFlowLayout(/*projectsToKeep*/ projects);
 
             // It's more efficient to update the project engine by loading engine infos once
             // instead of loading them all each time we want to know what project an engine uses
@@ -352,39 +292,45 @@ namespace O3DE::ProjectManager
                 }
 
                 // Check whether project manager has successfully built the project
-                if (currentButton)
-                {
-                    m_projectsFlowLayout->addWidget(currentButton);
+                AZ_Assert(currentButton, "Invalid ProjectButton");
 
-                    bool projectBuiltSuccessfully = false;
-                    SettingsInterface::Get()->GetProjectBuiltSuccessfully(projectBuiltSuccessfully, project);
+                m_projectsFlowLayout->addWidget(currentButton);
 
-                    if (!projectBuiltSuccessfully)
-                    {
-                        currentButton->SetState(ProjectButtonState::NeedsToBuild);
-                    }
+                bool projectBuiltSuccessfully = false;
+                SettingsInterface::Get()->GetProjectBuiltSuccessfully(projectBuiltSuccessfully, project);
+                if (!projectBuiltSuccessfully)
+                {
+                    currentButton->SetState(ProjectButtonState::NeedsToBuild);
+                }
 
-                    if (project.m_remote)
-                    {
-                        currentButton->SetState(ProjectButtonState::NotDownloaded);
-                        currentButton->SetProjectButtonAction(
-                            tr("Download Project"),
-                            [this, currentButton, project]
-                            {
-                                m_downloadController->AddObjectDownload(project.m_projectName, "", DownloadController::DownloadObjectType::Project);
-                                currentButton->SetState(ProjectButtonState::Downloading);
-                            });
-                    }
+                if (project.m_remote)
+                {
+                    currentButton->SetState(ProjectButtonState::NotDownloaded);
+                    currentButton->SetProjectButtonAction(
+                        tr("Download Project"),
+                        [this, currentButton, project]
+                        {
+                            m_downloadController->AddObjectDownload(project.m_projectName, "", DownloadController::DownloadObjectType::Project);
+                            currentButton->SetState(ProjectButtonState::Downloading);
+                        });
                 }
             }
 
-            // Setup building button again
-            auto buildProjectIter = m_projectButtons.find(buildProjectPath);
-            if (buildProjectIter != m_projectButtons.end())
+            if (m_currentBuilder)
             {
-                m_currentBuilder->SetProjectButton(buildProjectIter->second);
+                AZ::IO::Path buildProjectPath = AZ::IO::Path(m_currentBuilder->GetProjectInfo().m_path.toUtf8().constData());
+                if (!buildProjectPath.empty())
+                {
+                    // Setup building button again
+                    auto buildProjectIter = m_projectButtons.find(buildProjectPath);
+                    if (buildProjectIter != m_projectButtons.end())
+                    {
+                        m_currentBuilder->SetProjectButton(buildProjectIter->second);
+                    }
+                }
             }
 
+            // Let the user can cancel builds for projects in the build queue
             for (const ProjectInfo& project : m_buildQueue)
             {
                 auto projectIter = m_projectButtons.find(project.m_path.toUtf8().constData());
@@ -400,6 +346,7 @@ namespace O3DE::ProjectManager
                 }
             }
 
+            // Update the project build status if it requires building
             for (const ProjectInfo& project : m_requiresBuild)
             {
                 auto projectIter = m_projectButtons.find(project.m_path.toUtf8().constData());
@@ -443,7 +390,7 @@ namespace O3DE::ProjectManager
     void ProjectsScreen::HandleProjectFilePathChanged(const QString& /*path*/)
     {
         // QFileWatcher automatically stops watching the path if it was removed so we will just refresh our view
-        ResetProjectsContent();
+        UpdateIfCurrentScreen();
     }
 
     ProjectManagerScreen ProjectsScreen::GetScreenEnum()
@@ -514,55 +461,9 @@ namespace O3DE::ProjectManager
         QString path = QDir::toNativeSeparators(QFileDialog::getExistingDirectory(this, title, defaultPath));
         if (!path.isEmpty())
         {
-            // check if this project is compatible with this engine
-            auto incompatibleObjectsResult = PythonBindingsInterface::Get()->GetProjectEngineIncompatibleObjects(path);
-
-            AZStd::string errorTitle, generalError, detailedError;
-            if (!incompatibleObjectsResult)
-            {
-                errorTitle = "Failed to check project compatibility";
-                generalError = incompatibleObjectsResult.GetError().first;
-                generalError.append("\nDo you still want to add this project?");
-                detailedError = incompatibleObjectsResult.GetError().second;
-            }
-            else if (const auto& incompatibleObjects = incompatibleObjectsResult.GetValue(); !incompatibleObjects.isEmpty())
-            {
-                // provide a couple more user friendly error messages for uncommon cases
-                if (incompatibleObjects.at(0).contains("engine.json", Qt::CaseInsensitive))
-                {
-                    errorTitle = "Failed to read engine.json";
-                    generalError = "The projects compatibility with this engine could not be checked because the engine.json could not be read";
-                }
-                else if (incompatibleObjects.at(0).contains("project.json", Qt::CaseInsensitive))
-                {
-                    errorTitle = "Invalid project, failed to read project.json";
-                    generalError = "The projects compatibility with this engine could not be checked because the project.json could not be read.";
-                }
-                else
-                {
-                    // could be gems, apis or both
-                    errorTitle = "Project may not be compatible with this engine";
-                    generalError = incompatibleObjects.join("\n").toUtf8().constData();
-                    generalError.append("\nDo you still want to add this project?");
-                }
-            }
-
-            if (!generalError.empty())
-            {
-                QMessageBox warningDialog(QMessageBox::Warning, errorTitle.c_str(), generalError.c_str(), QMessageBox::Yes | QMessageBox::No, this);
-                warningDialog.setDetailedText(detailedError.c_str());
-                if(warningDialog.exec() == QMessageBox::No)
-                {
-                    return;
-                }
-                AZ_Warning("ProjectManager", false, "Proceeding with project registration after compatibility check failed.");
-            }
-
-            if (auto addProjectResult = PythonBindingsInterface::Get()->AddProject(path, /*force=*/true); !addProjectResult)
-            {
-                ProjectUtils::DisplayDetailedError(tr("Failed to add project"), addProjectResult, this);
-            }
-            else
+            // RegisterProject will check compatibility and prompt user to continue if issues found
+            // it will also handle detailed error messaging
+            if(ProjectUtils::RegisterProject(path, this))
             {
                 // notify the user the project was added successfully
                 emit ChangeScreenRequest(ProjectManagerScreen::Projects);
@@ -697,9 +598,16 @@ namespace O3DE::ProjectManager
     {
         if (!WarnIfInBuildQueue(projectPath))
         {
+            QString projectName = tr("Project");
+            auto getProjectResult = PythonBindingsInterface::Get()->GetProject(projectPath);
+            if (getProjectResult)
+            {
+                projectName = getProjectResult.GetValue().m_displayName;
+            }
+
             QMessageBox::StandardButton warningResult = QMessageBox::warning(this,
-                tr("Delete Project"),
-                tr("Are you sure?\nProject will be unregistered from O3DE and project directory will be deleted from your disk."),
+                tr("Delete %1").arg(projectName),
+                tr("%1 will be unregistered from O3DE and the project directory '%2' will be deleted from your disk.\n\nAre you sure you want to delete %1?").arg(projectName, projectPath),
                 QMessageBox::No | QMessageBox::Yes);
 
             if (warningResult == QMessageBox::Yes)
@@ -721,7 +629,8 @@ namespace O3DE::ProjectManager
         {
             m_requiresBuild.append(projectInfo);
         }
-        ResetProjectsContent();
+
+        UpdateIfCurrentScreen();
 
         if (showMessage)
         {
@@ -754,7 +663,7 @@ namespace O3DE::ProjectManager
             else
             {
                 m_buildQueue.append(projectInfo);
-                ResetProjectsContent();
+                UpdateIfCurrentScreen();
             }
         }
     }
@@ -762,7 +671,7 @@ namespace O3DE::ProjectManager
     void ProjectsScreen::UnqueueBuildProject(const ProjectInfo& projectInfo)
     {
         m_buildQueue.removeAll(projectInfo);
-        ResetProjectsContent();
+        UpdateIfCurrentScreen();
     }
 
     void ProjectsScreen::StartProjectDownload(const QString& projectName, const QString& destinationPath, bool queueBuild)
@@ -823,7 +732,7 @@ namespace O3DE::ProjectManager
         }
         else
         {
-            ResetProjectsContent();
+            UpdateIfCurrentScreen();
         }
     }
 
@@ -848,9 +757,83 @@ namespace O3DE::ProjectManager
         }
     }
 
+    QVector<ProjectInfo> ProjectsScreen::GetAllProjects()
+    {
+        QVector<ProjectInfo> projects;
+
+        auto projectsResult = PythonBindingsInterface::Get()->GetProjects();
+        if (projectsResult.IsSuccess() && !projectsResult.GetValue().isEmpty())
+        {
+            projects.append(projectsResult.GetValue());
+        }
+
+        auto remoteProjectsResult = PythonBindingsInterface::Get()->GetProjectsForAllRepos();
+        if (remoteProjectsResult.IsSuccess() && !remoteProjectsResult.GetValue().isEmpty())
+        {
+            for (const ProjectInfo& remoteProject : remoteProjectsResult.TakeValue())
+            {
+                auto foundProject = AZStd::ranges::find_if( projects,
+                    [&remoteProject](const ProjectInfo& value)
+                    {
+                        return remoteProject.m_id == value.m_id;
+                    });
+                if (foundProject == projects.end())
+                {
+                    projects.append(remoteProject);
+                }
+            }
+        }
+
+        AZ::IO::Path buildProjectPath;
+        if (m_currentBuilder)
+        {
+            buildProjectPath = AZ::IO::Path(m_currentBuilder->GetProjectInfo().m_path.toUtf8().constData());
+        }
+
+        // Sort the projects, putting currently building project in front, then queued projects, then sorts alphabetically
+        AZStd::sort(projects.begin(), projects.end(), [buildProjectPath, this](const ProjectInfo& arg1, const ProjectInfo& arg2)
+        {
+            if (!buildProjectPath.empty())
+            {
+                if (AZ::IO::Path(arg1.m_path.toUtf8().constData()) == buildProjectPath)
+                {
+                    return true;
+                }
+                else if (AZ::IO::Path(arg2.m_path.toUtf8().constData()) == buildProjectPath)
+                {
+                    return false;
+                }
+            }
+
+            bool arg1InBuildQueue = BuildQueueContainsProject(arg1.m_path);
+            bool arg2InBuildQueue = BuildQueueContainsProject(arg2.m_path);
+            if (arg1InBuildQueue && !arg2InBuildQueue)
+            {
+                return true;
+            }
+            else if (!arg1InBuildQueue && arg2InBuildQueue)
+            {
+                return false;
+            }
+            else if (arg1.m_displayName.compare(arg2.m_displayName, Qt::CaseInsensitive) == 0)
+            {
+                // handle case where names are the same
+                return arg1.m_path.toLower() < arg2.m_path.toLower();
+            }
+            else
+            {
+                return arg1.m_displayName.toLower() < arg2.m_displayName.toLower();
+            }
+        });
+
+        return projects;
+    }
+
     void ProjectsScreen::NotifyCurrentScreen()
     {
-        if (ShouldDisplayFirstTimeContent())
+        const QVector<ProjectInfo>& projects = GetAllProjects();
+        const bool projectsFound = !projects.isEmpty();
+        if (ShouldDisplayFirstTimeContent(projectsFound))
         {
             m_background.load(":/Backgrounds/FtueBackground.jpg");
             m_stack->setCurrentWidget(m_firstTimeContent);
@@ -858,22 +841,18 @@ namespace O3DE::ProjectManager
         else
         {
             m_background.load(":/Backgrounds/DefaultBackground.jpg");
-            ResetProjectsContent();
+            UpdateWithProjects(projects);
         }
     }
 
-    bool ProjectsScreen::ShouldDisplayFirstTimeContent()
+    bool ProjectsScreen::ShouldDisplayFirstTimeContent(bool projectsFound)
     {
-        auto projectsResult = PythonBindingsInterface::Get()->GetProjects();
-        auto remoteProjectsResult = PythonBindingsInterface::Get()->GetProjectsForAllRepos();
-
-        // If we do not have any local or remote projects to show, then show the first time content
-        if ((!projectsResult.IsSuccess() || projectsResult.GetValue().isEmpty()) &&
-            (!remoteProjectsResult.IsSuccess() || remoteProjectsResult.GetValue().isEmpty()))
+        if (projectsFound)
         {
-            return true;
+            return false;
         }
 
+        // only show this screen once
         QSettings settings;
         bool displayFirstTimeContent = settings.value("displayFirstTimeContent", true).toBool();
         if (displayFirstTimeContent)
@@ -884,11 +863,6 @@ namespace O3DE::ProjectManager
         return displayFirstTimeContent;
     }
 
-    bool ProjectsScreen::RemoveInvalidProjects()
-    {
-        return PythonBindingsInterface::Get()->RemoveInvalidProjects();
-    }
-
     bool ProjectsScreen::StartProjectBuild(const ProjectInfo& projectInfo)
     {
         if (ProjectUtils::FindSupportedCompiler(this))
@@ -902,7 +876,7 @@ namespace O3DE::ProjectManager
             if (buildProject == QMessageBox::Yes)
             {
                 m_currentBuilder = new ProjectBuilderController(projectInfo, nullptr, this);
-                ResetProjectsContent();
+                UpdateWithProjects(GetAllProjects());
                 connect(m_currentBuilder, &ProjectBuilderController::Done, this, &ProjectsScreen::ProjectBuildDone);
                 connect(m_currentBuilder, &ProjectBuilderController::NotifyBuildProject, this, &ProjectsScreen::SuggestBuildProject);
 
@@ -945,7 +919,7 @@ namespace O3DE::ProjectManager
             m_buildQueue.pop_front();
         }
 
-        ResetProjectsContent();
+        UpdateIfCurrentScreen();
     }
 
     QList<ProjectInfo>::iterator ProjectsScreen::RequiresBuildProjectIterator(const QString& projectPath)

+ 6 - 3
Code/Tools/ProjectManager/Source/ProjectsScreen.h

@@ -17,6 +17,7 @@
 #include <DownloadController.h>
 
 #include <QQueue>
+#include <QVector>
 #endif
 
 QT_FORWARD_DECLARE_CLASS(QPaintEvent)
@@ -77,9 +78,11 @@ namespace O3DE::ProjectManager
         QFrame* CreateFirstTimeContent();
         QFrame* CreateProjectsContent();
         ProjectButton* CreateProjectButton(const ProjectInfo& project, const EngineInfo& engine);
-        void ResetProjectsContent();
-        bool ShouldDisplayFirstTimeContent();
-        bool RemoveInvalidProjects();
+        QVector<ProjectInfo> GetAllProjects();
+        void UpdateWithProjects(const QVector<ProjectInfo>& projects);
+        void UpdateIfCurrentScreen();
+        bool ShouldDisplayFirstTimeContent(bool projectsFound);
+        void RemoveProjectButtonsFromFlowLayout(const QVector<ProjectInfo>& projectsToKeep);
 
         bool StartProjectBuild(const ProjectInfo& projectInfo);
         QList<ProjectInfo>::iterator RequiresBuildProjectIterator(const QString& projectPath);

+ 211 - 104
Code/Tools/ProjectManager/Source/PythonBindings.cpp

@@ -644,6 +644,10 @@ namespace O3DE::ProjectManager
         gemInfo.m_requirement = Py_To_String_Optional(data, "requirements", "");
         gemInfo.m_origin = Py_To_String_Optional(data, "origin", "");
         gemInfo.m_originURL = Py_To_String_Optional(data, "origin_url", "");
+        gemInfo.m_downloadSourceUri = Py_To_String_Optional(data, "origin_uri", "");
+        gemInfo.m_downloadSourceUri = Py_To_String_Optional(data, "download_source_uri", gemInfo.m_downloadSourceUri);
+        gemInfo.m_sourceControlUri = Py_To_String_Optional(data, "source_control_uri", "");
+        gemInfo.m_sourceControlRef = Py_To_String_Optional(data, "source_control_ref", "");
         gemInfo.m_documentationLink = Py_To_String_Optional(data, "documentation_url", "");
         gemInfo.m_iconPath = Py_To_String_Optional(data, "icon_path", "preview.png");
         gemInfo.m_licenseText = Py_To_String_Optional(data, "license", "Unspecified License");
@@ -719,6 +723,22 @@ namespace O3DE::ProjectManager
                 gemInfo.m_compatibleEngines.push_back(Py_To_String(compatible_engine));
             }
         }
+
+        if (data.contains("incompatible_engine_dependencies"))
+        {
+            for (auto incompatible_dependency : data["incompatible_engine_dependencies"])
+            {
+                gemInfo.m_incompatibleEngineDependencies.push_back(Py_To_String(incompatible_dependency));
+            }
+        }
+
+        if (data.contains("incompatible_gem_dependencies"))
+        {
+            for (auto incompatible_dependency : data["incompatible_gem_dependencies"])
+            {
+                gemInfo.m_incompatibleGemDependencies.push_back(Py_To_String(incompatible_dependency));
+            }
+        }
     }
 
     AZ::Outcome<GemInfo> PythonBindings::GetGemInfo(const QString& path, const QString& projectPath)
@@ -772,9 +792,15 @@ namespace O3DE::ProjectManager
             {
                 for (auto item : pybind11::dict(enabledGemsData))
                 {
-                    enabledGems.insert(Py_To_String(item.first), Py_To_String(item.second));
+                    // check for missing gem paths here otherwise case will convert the None type to "None"
+                    // which looks like an incorrect path instead of a missing path
+                    enabledGems.insert(Py_To_String(item.first), pybind11::isinstance<pybind11::none>(item.second) ? "" : Py_To_String(item.second));
                 }
             }
+            else
+            {
+                throw std::runtime_error("Failed to get the active gems for project");
+            }
         });
 
         if (!result.IsSuccess())
@@ -889,7 +915,7 @@ namespace O3DE::ProjectManager
         return AZ::Success();
     }
 
-    AZ::Outcome<ProjectInfo> PythonBindings::CreateProject(const QString& projectTemplatePath, const ProjectInfo& projectInfo, bool registerProject)
+    AZ::Outcome<ProjectInfo, IPythonBindings::ErrorPair> PythonBindings::CreateProject(const QString& projectTemplatePath, const ProjectInfo& projectInfo, bool registerProject)
     {
         using namespace pybind11::literals;
 
@@ -912,7 +938,7 @@ namespace O3DE::ProjectManager
 
         if (!result || !createdProjectInfo.IsValid())
         {
-            return AZ::Failure();
+            return AZ::Failure(GetErrorPair());
         }
         else
         {
@@ -1075,6 +1101,28 @@ namespace O3DE::ProjectManager
             }
         }
 
+        if (projectData.contains("gem_names"))
+        {
+            for (auto gem : projectData["gem_names"])
+            {
+                if (pybind11::isinstance<pybind11::dict>(gem))
+                {
+                    if (gem["optional"].cast<bool>())
+                    {
+                        projectInfo.m_optionalGemDependencies.append(Py_To_String(gem["name"]));
+                    }
+                    else
+                    {
+                        projectInfo.m_requiredGemDependencies.append(Py_To_String(gem["name"]));
+                    }
+                }
+                else
+                {
+                    projectInfo.m_requiredGemDependencies.append(Py_To_String(gem));
+                }
+            }
+        }
+
         if (projectData.contains("engine_path"))
         {
             // Python looked for an engine path so we don't need to, but be careful
@@ -1084,7 +1132,7 @@ namespace O3DE::ProjectManager
                 projectInfo.m_enginePath = Py_To_String(projectData["engine_path"]);
             }
         }
-        else
+        else if (!projectInfo.m_path.isEmpty())
         {
             auto enginePathResult = m_manifest.attr("get_project_engine_path")(QString_To_Py_Path(projectInfo.m_path));
             if (!pybind11::isinstance<pybind11::none>(enginePathResult))
@@ -1144,7 +1192,7 @@ namespace O3DE::ProjectManager
         }
     }
 
-    AZ::Outcome<QVector<ProjectInfo>, AZStd::string> PythonBindings::GetProjectsForRepo(const QString& repoUri)
+    AZ::Outcome<QVector<ProjectInfo>, AZStd::string> PythonBindings::GetProjectsForRepo(const QString& repoUri, bool enabledOnly)
     {
         QVector<ProjectInfo> projects;
 
@@ -1152,13 +1200,12 @@ namespace O3DE::ProjectManager
             [&]
             {
                 auto pyUri = QString_To_Py_String(repoUri);
-                auto projectPaths = m_repo.attr("get_project_json_paths_from_cached_repo")(pyUri);
-
-                if (pybind11::isinstance<pybind11::set>(projectPaths))
+                auto pyProjects = m_repo.attr("get_project_json_data_from_cached_repo")(pyUri, enabledOnly);
+                if (pybind11::isinstance<pybind11::list>(pyProjects))
                 {
-                    for (auto path : projectPaths)
+                    for (auto pyProjectJsonData : pyProjects)
                     {
-                        ProjectInfo projectInfo = ProjectInfoFromPath(path);
+                        ProjectInfo projectInfo = ProjectInfoFromDict(pyProjectJsonData);
                         projectInfo.m_remote = true;
                         projects.push_back(projectInfo);
                     }
@@ -1173,21 +1220,20 @@ namespace O3DE::ProjectManager
         return AZ::Success(AZStd::move(projects));
     }
 
-    AZ::Outcome<QVector<ProjectInfo>, AZStd::string> PythonBindings::GetProjectsForAllRepos()
+    AZ::Outcome<QVector<ProjectInfo>, AZStd::string> PythonBindings::GetProjectsForAllRepos(bool enabledOnly)
     {
-        QVector<ProjectInfo> projectInfos;
+        QVector<ProjectInfo> projects;
         AZ::Outcome<void, AZStd::string> result = ExecuteWithLockErrorHandling(
             [&]
             {
-                auto projectPaths = m_repo.attr("get_project_json_paths_from_all_cached_repos")();
-
-                if (pybind11::isinstance<pybind11::set>(projectPaths))
+                auto pyProjects = m_repo.attr("get_project_json_data_from_all_cached_repos")(enabledOnly);
+                if (pybind11::isinstance<pybind11::list>(pyProjects))
                 {
-                    for (auto path : projectPaths)
+                    for (auto pyProjectJsonData : pyProjects)
                     {
-                        ProjectInfo projectInfo = ProjectInfoFromPath(path);
+                        ProjectInfo projectInfo = ProjectInfoFromDict(pyProjectJsonData);
                         projectInfo.m_remote = true;
-                        projectInfos.push_back(projectInfo);
+                        projects.push_back(projectInfo);
                     }
                 }
             });
@@ -1197,7 +1243,7 @@ namespace O3DE::ProjectManager
             return AZ::Failure(result.GetError());
         }
 
-        return AZ::Success(AZStd::move(projectInfos));
+        return AZ::Success(AZStd::move(projects));
     }
 
     IPythonBindings::DetailedOutcome PythonBindings::AddGemsToProject(const QStringList& gemPaths, const QStringList& gemNames, const QString& projectPath, bool force)
@@ -1308,6 +1354,31 @@ namespace O3DE::ProjectManager
         return AZ::Success();
     }
 
+    ProjectTemplateInfo PythonBindings::ProjectTemplateInfoFromDict(pybind11::handle templateData, const QString& path) const
+    {
+        ProjectTemplateInfo templateInfo(TemplateInfoFromDict(templateData, path));
+        if (templateInfo.IsValid())
+        {
+            QString templateProjectPath = QDir(templateInfo.m_path).filePath("Template");
+            constexpr bool includeDependencies = false;
+            auto enabledGems = GetEnabledGems(templateProjectPath, includeDependencies);
+            if (enabledGems)
+            {
+                for (auto gemName : enabledGems.GetValue().keys())
+                {
+                    // Exclude the template ${Name} placeholder for the list of included gems
+                    // That Gem gets created with the project
+                    if (!gemName.contains("${Name}"))
+                    {
+                        templateInfo.m_includedGems.push_back(gemName);
+                    }
+                }
+            }
+        }
+
+        return templateInfo;
+    }
+
     ProjectTemplateInfo PythonBindings::ProjectTemplateInfoFromPath(pybind11::handle path) const
     {
         ProjectTemplateInfo templateInfo(TemplateInfoFromPath(path));
@@ -1341,6 +1412,45 @@ namespace O3DE::ProjectManager
         return templateInfo;
     }
 
+    TemplateInfo PythonBindings::TemplateInfoFromDict(pybind11::handle data, const QString& path) const
+    {
+        TemplateInfo templateInfo;
+        if (!path.isEmpty())
+        {
+            templateInfo.m_path = path;
+        }
+        else
+        {
+            templateInfo.m_path = Py_To_String_Optional(data, "path", templateInfo.m_path);
+        }
+
+        templateInfo.m_displayName = Py_To_String(data["display_name"]);
+        templateInfo.m_name = Py_To_String(data["template_name"]);
+        templateInfo.m_summary = Py_To_String(data["summary"]);
+
+        if (data.contains("canonical_tags"))
+        {
+            for (auto tag : data["canonical_tags"])
+            {
+                templateInfo.m_canonicalTags.push_back(Py_To_String(tag));
+            }
+        }
+
+        if (data.contains("user_tags"))
+        {
+            for (auto tag : data["user_tags"])
+            {
+                templateInfo.m_userTags.push_back(Py_To_String(tag));
+            }
+        }
+
+
+        templateInfo.m_requirements = Py_To_String_Optional(data, "requirements", "");
+        templateInfo.m_license = Py_To_String_Optional(data, "license", "");
+
+        return templateInfo;
+    }
+
     TemplateInfo PythonBindings::TemplateInfoFromPath(pybind11::handle path) const
     {
         TemplateInfo templateInfo;
@@ -1354,29 +1464,7 @@ namespace O3DE::ProjectManager
         {
             try
             {
-                templateInfo.m_displayName = Py_To_String(data["display_name"]);
-                templateInfo.m_name = Py_To_String(data["template_name"]);
-                templateInfo.m_summary = Py_To_String(data["summary"]);
-
-                if (data.contains("canonical_tags"))
-                {
-                    for (auto tag : data["canonical_tags"])
-                    {
-                        templateInfo.m_canonicalTags.push_back(Py_To_String(tag));
-                    }
-                }
-
-                if (data.contains("user_tags"))
-                {
-                    for (auto tag : data["user_tags"])
-                    {
-                        templateInfo.m_userTags.push_back(Py_To_String(tag));
-                    }
-                }
-
-
-                templateInfo.m_requirements = Py_To_String_Optional(data, "requirements", "");
-                templateInfo.m_license = Py_To_String_Optional(data, "license", "");
+                templateInfo = TemplateInfoFromDict(data, Py_To_String(path));
             }
             catch ([[maybe_unused]] const std::exception& e)
             {
@@ -1431,7 +1519,7 @@ namespace O3DE::ProjectManager
         }
     }
 
-    AZ::Outcome<QVector<ProjectTemplateInfo>> PythonBindings::GetProjectTemplatesForRepo(const QString& repoUri) const
+    AZ::Outcome<QVector<ProjectTemplateInfo>> PythonBindings::GetProjectTemplatesForRepo(const QString& repoUri, bool enabledOnly) const
     {
         QVector<ProjectTemplateInfo> templates;
 
@@ -1439,16 +1527,15 @@ namespace O3DE::ProjectManager
             [&]
             {
                 using namespace pybind11::literals;
-
-                auto templatePaths = m_repo.attr("get_template_json_paths_from_cached_repo")(
-                    "repo_uri"_a = QString_To_Py_String(repoUri)
+                auto pyTemplates = m_repo.attr("get_template_json_data_from_cached_repo")(
+                    "repo_uri"_a = QString_To_Py_String(repoUri), "enabled_only"_a = enabledOnly
                     );
 
-                if (pybind11::isinstance<pybind11::set>(templatePaths))
+                if (pybind11::isinstance<pybind11::list>(pyTemplates))
                 {
-                    for (auto path : templatePaths)
+                    for (auto pyTemplateJsonData : pyTemplates)
                     {
-                        ProjectTemplateInfo remoteTemplate = ProjectTemplateInfoFromPath(path);
+                        ProjectTemplateInfo remoteTemplate = TemplateInfoFromDict(pyTemplateJsonData);
                         remoteTemplate.m_isRemote = true;
                         templates.push_back(remoteTemplate);
                     }
@@ -1465,20 +1552,19 @@ namespace O3DE::ProjectManager
         }
     }
 
-    AZ::Outcome<QVector<ProjectTemplateInfo>> PythonBindings::GetProjectTemplatesForAllRepos() const
+    AZ::Outcome<QVector<ProjectTemplateInfo>> PythonBindings::GetProjectTemplatesForAllRepos(bool enabledOnly) const
     {
         QVector<ProjectTemplateInfo> templates;
 
         bool result = ExecuteWithLock(
             [&]
             {
-                auto templatePaths = m_repo.attr("get_template_json_paths_from_all_cached_repos")();
-
-                if (pybind11::isinstance<pybind11::set>(templatePaths))
+                auto pyTemplates = m_repo.attr("get_template_json_data_from_all_cached_repos")(enabledOnly);
+                if (pybind11::isinstance<pybind11::list>(pyTemplates))
                 {
-                    for (auto path : templatePaths)
+                    for (auto pyTemplateJsonData : pyTemplates)
                     {
-                        ProjectTemplateInfo remoteTemplate = ProjectTemplateInfoFromPath(path);
+                        ProjectTemplateInfo remoteTemplate = ProjectTemplateInfoFromDict(pyTemplateJsonData);
                         remoteTemplate.m_isRemote = true;
                         templates.push_back(remoteTemplate);
                     }
@@ -1495,14 +1581,14 @@ namespace O3DE::ProjectManager
         }
     }
 
-    AZ::Outcome<void, AZStd::string> PythonBindings::RefreshGemRepo(const QString& repoUri)
+    AZ::Outcome<void, AZStd::string> PythonBindings::RefreshGemRepo(const QString& repoUri, bool downloadMissingOnly)
     {
         bool refreshResult = false;
         AZ::Outcome<void, AZStd::string> result = ExecuteWithLockErrorHandling(
             [&]
             {
                 auto pyUri = QString_To_Py_String(repoUri);
-                auto pythonRefreshResult = m_repo.attr("refresh_repo")(pyUri);
+                auto pythonRefreshResult = m_repo.attr("refresh_repo")(pyUri, downloadMissingOnly);
 
                 // Returns an exit code so boolify it then invert result
                 refreshResult = !pythonRefreshResult.cast<bool>();
@@ -1520,13 +1606,13 @@ namespace O3DE::ProjectManager
         return AZ::Success();
     }
 
-    bool PythonBindings::RefreshAllGemRepos()
+    bool PythonBindings::RefreshAllGemRepos(bool downloadMissingOnly)
     {
         bool refreshResult = false;
         bool result = ExecuteWithLock(
             [&]
             {
-                auto pythonRefreshResult = m_repo.attr("refresh_repos")();
+                auto pythonRefreshResult = m_repo.attr("refresh_repos")(downloadMissingOnly);
 
                 // Returns an exit code so boolify it then invert result
                 refreshResult = !pythonRefreshResult.cast<bool>();
@@ -1637,24 +1723,9 @@ namespace O3DE::ProjectManager
                 using namespace pybind11::literals;
 
                 auto pythonRegistrationResult = m_register.attr("register")(
-                    "engine_path"_a                  = pybind11::none(),
-                    "project_path"_a                 = pybind11::none(),
-                    "gem_path"_a                     = pybind11::none(),
-                    "external_subdir_path"_a         = pybind11::none(),
-                    "template_path"_a                = pybind11::none(),
-                    "restricted_path"_a              = pybind11::none(),
-                    "repo_uri"_a                     = QString_To_Py_String(repoUri),
-                    "default_engines_folder"_a       = pybind11::none(),
-                    "default_projects_folder"_a      = pybind11::none(),
-                    "default_gems_folder"_a          = pybind11::none(),
-                    "default_templates_folder"_a     = pybind11::none(),
-                    "default_restricted_folder"_a    = pybind11::none(),
-                    "default_third_party_folder"_a   = pybind11::none(),
-                    "external_subdir_engine_path"_a  = pybind11::none(),
-                    "external_subdir_project_path"_a = pybind11::none(),
-                    "external_subdir_gem_path"_a     = pybind11::none(),
-                    "remove"_a                       = true,
-                    "force"_a                        = false
+                    "repo_uri"_a = QString_To_Py_String(repoUri),
+                    "remove"_a   = true,
+                    "force"_a    = false
                     );
 
                 // Returns an exit code so boolify it then invert result
@@ -1664,6 +1735,23 @@ namespace O3DE::ProjectManager
         return result && registrationResult;
     }
 
+
+    bool PythonBindings::SetRepoEnabled(const QString& repoUri, bool enabled)
+    {
+        bool enableResult = false;
+        bool result = ExecuteWithLock(
+            [&]
+            {
+                auto pyResult = m_projectManagerInterface.attr("set_repo_enabled")(
+                    QString_To_Py_String(repoUri), enabled);
+
+                // Returns an exit code so boolify it then invert result
+                enableResult = !pyResult.cast<bool>();
+            });
+
+        return result && enableResult;
+    }
+
     GemRepoInfo PythonBindings::GetGemRepoInfo(pybind11::handle repoUri)
     {
         GemRepoInfo gemRepoInfo;
@@ -1685,8 +1773,28 @@ namespace O3DE::ProjectManager
                 auto repoPath = m_manifest.attr("get_repo_path")(repoUri);
                 gemRepoInfo.m_path = gemRepoInfo.m_directoryLink = Py_To_String(repoPath);
 
-                QString lastUpdated = Py_To_String_Optional(data, "last_updated", "");
-                gemRepoInfo.m_lastUpdated = QDateTime::fromString(lastUpdated, RepoTimeFormat);
+                const QString lastUpdated = Py_To_String_Optional(data, "last_updated", "");
+
+                // first attempt to read in the ISO8601 UTC python format with milliseconds
+                gemRepoInfo.m_lastUpdated = QDateTime::fromString(lastUpdated, Qt::ISODateWithMs).toLocalTime();
+                if (!gemRepoInfo.m_lastUpdated.isValid())
+                {
+                    // try without milliseconds
+                    gemRepoInfo.m_lastUpdated = QDateTime::fromString(lastUpdated, Qt::ISODate).toLocalTime();
+                }
+
+                if (!gemRepoInfo.m_lastUpdated.isValid())
+                {
+                    const QStringList legacyFormats{ "dd/MM/yyyy HH:mm", RepoTimeFormat };
+                    for (auto format : legacyFormats)
+                    {
+                        gemRepoInfo.m_lastUpdated = QDateTime::fromString(lastUpdated, format);
+                        if (gemRepoInfo.m_lastUpdated.isValid())
+                        {
+                            break;
+                        }
+                    }
+                }
 
                 if (data.contains("enabled"))
                 {
@@ -1694,15 +1802,16 @@ namespace O3DE::ProjectManager
                 }
                 else
                 {
-                    gemRepoInfo.m_isEnabled = false;
+                    gemRepoInfo.m_isEnabled = true;
                 }
 
-                if (data.contains("gems"))
+                if (gemRepoInfo.m_repoUri.compare(CanonicalRepoUri, Qt::CaseInsensitive) == 0)
                 {
-                    for (auto gemPath : data["gems"])
-                    {
-                        gemRepoInfo.m_includedGemUris.push_back(Py_To_String(gemPath));
-                    }
+                    gemRepoInfo.m_badgeType = GemRepoInfo::BadgeType::BlueBadge;
+                }
+                else
+                {
+                    gemRepoInfo.m_badgeType = GemRepoInfo::BadgeType::NoBadge;
                 }
             }
             catch ([[maybe_unused]] const std::exception& e)
@@ -1735,20 +1844,20 @@ namespace O3DE::ProjectManager
         return AZ::Success(AZStd::move(gemRepos));
     }
 
-    AZ::Outcome<QVector<GemInfo>, AZStd::string> PythonBindings::GetGemInfosForRepo(const QString& repoUri)
+    AZ::Outcome<QVector<GemInfo>, AZStd::string> PythonBindings::GetGemInfosForRepo(const QString& repoUri, bool enabledOnly)
     {
         QVector<GemInfo> gemInfos;
         AZ::Outcome<void, AZStd::string> result = ExecuteWithLockErrorHandling(
             [&]
             {
                 auto pyUri = QString_To_Py_String(repoUri);
-                auto gemPaths = m_repo.attr("get_gem_json_paths_from_cached_repo")(pyUri);
-
-                if (pybind11::isinstance<pybind11::set>(gemPaths))
+                auto pyGems = m_repo.attr("get_gem_json_data_from_cached_repo")(pyUri, enabledOnly);
+                if (pybind11::isinstance<pybind11::list>(pyGems))
                 {
-                    for (auto path : gemPaths)
+                    for (auto pyGemJsonData : pyGems)
                     {
-                        GemInfo gemInfo = GemInfoFromPath(path, pybind11::none());
+                        GemInfo gemInfo;
+                        GetGemInfoFromPyDict(gemInfo, pyGemJsonData.cast<pybind11::dict>());
                         gemInfo.m_downloadStatus = GemInfo::DownloadStatus::NotDownloaded;
                         gemInfo.m_gemOrigin = GemInfo::Remote;
                         gemInfos.push_back(AZStd::move(gemInfo));
@@ -1764,23 +1873,21 @@ namespace O3DE::ProjectManager
         return AZ::Success(AZStd::move(gemInfos));
     }
 
-    AZ::Outcome<QVector<GemInfo>, AZStd::string> PythonBindings::GetGemInfosForAllRepos()
+    AZ::Outcome<QVector<GemInfo>, AZStd::string> PythonBindings::GetGemInfosForAllRepos(const QString& projectPath, bool enabledOnly)
     {
-        QVector<GemInfo> gemInfos;
+        QVector<GemInfo> gems;
         AZ::Outcome<void, AZStd::string> result = ExecuteWithLockErrorHandling(
             [&]
             {
-                auto gemPaths = m_repo.attr("get_gem_json_paths_from_all_cached_repos")();
-
-                if (pybind11::isinstance<pybind11::set>(gemPaths))
+                const auto pyProjectPath = QString_To_Py_Path(projectPath);
+                const auto gemInfos = m_projectManagerInterface.attr("get_gem_infos_from_all_repos")(pyProjectPath, enabledOnly);
+                for (pybind11::handle pyGemJsonData : gemInfos)
                 {
-                    for (auto path : gemPaths)
-                    {
-                        GemInfo gemInfo = GemInfoFromPath(path, pybind11::none());
-                        gemInfo.m_downloadStatus = GemInfo::DownloadStatus::NotDownloaded;
-                        gemInfo.m_gemOrigin = GemInfo::Remote;
-                        gemInfos.push_back(AZStd::move(gemInfo));
-                    }
+                    GemInfo gemInfo;
+                    GetGemInfoFromPyDict(gemInfo, pyGemJsonData.cast<pybind11::dict>());
+                    gemInfo.m_downloadStatus = GemInfo::DownloadStatus::NotDownloaded;
+                    gemInfo.m_gemOrigin = GemInfo::Remote;
+                    gems.push_back(AZStd::move(gemInfo));
                 }
             });
 
@@ -1789,7 +1896,7 @@ namespace O3DE::ProjectManager
             return AZ::Failure(result.GetError());
         }
 
-        return AZ::Success(AZStd::move(gemInfos));
+        return AZ::Success(AZStd::move(gems));
     }
 
     IPythonBindings::DetailedOutcome  PythonBindings::DownloadGem(

+ 13 - 10
Code/Tools/ProjectManager/Source/PythonBindings.h

@@ -54,11 +54,11 @@ namespace O3DE::ProjectManager
         AZ::Outcome<void, AZStd::string> UnregisterGem(const QString& gemPath, const QString& projectPath = {}) override;
 
         // Project
-        AZ::Outcome<ProjectInfo> CreateProject(const QString& projectTemplatePath, const ProjectInfo& projectInfo, bool registerProject = true) override;
+        AZ::Outcome<ProjectInfo, IPythonBindings::ErrorPair> CreateProject(const QString& projectTemplatePath, const ProjectInfo& projectInfo, bool registerProject = true) override;
         AZ::Outcome<ProjectInfo> GetProject(const QString& path) override;
         AZ::Outcome<QVector<ProjectInfo>> GetProjects() override;
-        AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForRepo(const QString& repoUri) override;
-        AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForAllRepos() override;
+        AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForRepo(const QString& repoUri, bool enabledOnly = true) override;
+        AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForAllRepos(bool enabledOnly = true) override;
         DetailedOutcome AddProject(const QString& path, bool force = false) override;
         DetailedOutcome RemoveProject(const QString& path) override;
         AZ::Outcome<void, AZStd::string> UpdateProject(const ProjectInfo& projectInfo) override;
@@ -71,14 +71,15 @@ namespace O3DE::ProjectManager
         AZ::Outcome<void, AZStd::string> RemoveGemFromProject(const QString& gemName, const QString& projectPath) override;
         bool RemoveInvalidProjects() override;
 
-        // Gem Repos
-        AZ::Outcome<void, AZStd::string> RefreshGemRepo(const QString& repoUri) override;
-        bool RefreshAllGemRepos() override;
+        // Remote Repos
+        AZ::Outcome<void, AZStd::string> RefreshGemRepo(const QString& repoUri, bool downloadMissingOnly = false) override;
+        bool RefreshAllGemRepos(bool downloadMissingOnly = false) override;
         DetailedOutcome AddGemRepo(const QString& repoUri) override;
         bool RemoveGemRepo(const QString& repoUri) override;
+        bool SetRepoEnabled(const QString& repoUri, bool enabled) override;
         AZ::Outcome<QVector<GemRepoInfo>, AZStd::string> GetAllGemRepoInfos() override;
-        AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForRepo(const QString& repoUri) override;
-        AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForAllRepos() override;
+        AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForRepo(const QString& repoUri, bool enabledOnly = true) override;
+        AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForAllRepos(const QString& projectPath, bool enabledOnly = true) override;
         DetailedOutcome DownloadGem(
             const QString& gemName, const QString& path, std::function<void(int, int)> gemProgressCallback, bool force = false) override;
         DetailedOutcome DownloadProject(
@@ -90,8 +91,8 @@ namespace O3DE::ProjectManager
 
         // Templates
         AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplates() override;
-        AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForRepo(const QString& repoUri) const override;
-        AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForAllRepos() const override;
+        AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForRepo(const QString& repoUri, bool enabledOnly = true) const override;
+        AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForAllRepos(bool enabledOnly = true) const override;
         AZ::Outcome<QVector<TemplateInfo>> GetGemTemplates() override;
 
         void AddErrorString(AZStd::string errorString) override;
@@ -111,7 +112,9 @@ namespace O3DE::ProjectManager
         ProjectInfo ProjectInfoFromPath(pybind11::handle path);
         ProjectInfo ProjectInfoFromDict(pybind11::handle projectData, const QString& path = {});
         ProjectTemplateInfo ProjectTemplateInfoFromPath(pybind11::handle path) const;
+        ProjectTemplateInfo ProjectTemplateInfoFromDict(pybind11::handle templateData, const QString& path = {}) const;
         TemplateInfo TemplateInfoFromPath(pybind11::handle path) const;
+        TemplateInfo TemplateInfoFromDict(pybind11::handle templateData, const QString& path = {}) const;
         AZ::Outcome<void, AZStd::string> GemRegistration(const QString& gemPath, const QString& projectPath, bool remove = false);
         bool StopPython();
         IPythonBindings::ErrorPair GetErrorPair();

+ 31 - 12
Code/Tools/ProjectManager/Source/PythonBindingsInterface.h

@@ -182,7 +182,7 @@ namespace O3DE::ProjectManager
          * @param registerProject whether to register the project or not
          * @return an outcome with ProjectInfo on success 
          */
-        virtual AZ::Outcome<ProjectInfo> CreateProject(const QString& projectTemplatePath, const ProjectInfo& projectInfo, bool registerProject = true) = 0;
+        virtual AZ::Outcome<ProjectInfo, IPythonBindings::ErrorPair> CreateProject(const QString& projectTemplatePath, const ProjectInfo& projectInfo, bool registerProject = true) = 0;
 
         /**
          * Get info about a project 
@@ -200,15 +200,17 @@ namespace O3DE::ProjectManager
         /**
          * Gathers all projects from the provided repo
          * @param repoUri the absolute filesystem path or url to the gem repo.
+         * @param enabledOnly Whether to only include enabled repos 
          * @return A list of project infos or an error string on failure.
          */
-        virtual AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForRepo(const QString& repoUri) = 0;
+        virtual AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForRepo(const QString& repoUri, bool enabledOnly = true) = 0;
 
         /**
          * Gathers all projects from all registered repos
+         * @param enabledOnly Whether to only include enabled repos 
          * @return A list of project infos or an error string on failure.
          */
-        virtual AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForAllRepos() = 0;
+        virtual AZ::Outcome<QVector<ProjectInfo>, AZStd::string> GetProjectsForAllRepos(bool enabledOnly = true) = 0;
         
         /**
          * Adds existing project on disk
@@ -243,7 +245,7 @@ namespace O3DE::ProjectManager
         virtual DetailedOutcome AddGemsToProject(const QStringList& gemPaths, const QStringList& gemNames, const QString& projectPath, bool force = false) = 0;
 
         /**
-         * Get gems that are incompatibile with this project 
+         * Get gems that are incompatible with this project 
          * @param gemPaths the absolute paths to the gems
          * @param gemNames the names of the gems to add with optional version specifiers
          * @param projectPath the absolute path to the project
@@ -252,7 +254,7 @@ namespace O3DE::ProjectManager
         virtual AZ::Outcome<QStringList, AZStd::string> GetIncompatibleProjectGems(const QStringList& gemPaths, const QStringList& gemNames, const QString& projectPath) = 0;
 
         /**
-         * Get objects that are incompatibile with the provided project and engine.
+         * Get objects that are incompatible with the provided project and engine.
          * The objects could be engine APIs or gems dependencies that might prevent this project from compiling
          * with the engine.
          * @param projectPath the absolute path to the project
@@ -286,30 +288,35 @@ namespace O3DE::ProjectManager
 
         /**
          * Gathers all project templates for the given repo.
+         * @param repoUri The repo URI
+         * @param enabledOnly Whether to only include enabled repos 
          * @return An outcome with a list of all ProjectTemplateInfos from the given repo on success
          */
-        virtual AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForRepo(const QString& repoUri) const = 0;
+        virtual AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForRepo(const QString& repoUri, bool enabledOnly = true) const = 0;
 
         /**
          * Gathers all project templates for all templates registered from repos.
+         * @param enabledOnly Whether to only include enabled repos 
          * @return An outcome with a list of all ProjectTemplateInfos on success
          */
-        virtual AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForAllRepos() const = 0;
+        virtual AZ::Outcome<QVector<ProjectTemplateInfo>> GetProjectTemplatesForAllRepos(bool enabledOnly = true) const = 0;
 
-        // Gem Repos
+        // Remote Repos
 
         /**
          * Refresh gem repo in the current engine.
          * @param repoUri the absolute filesystem path or url to the gem repo.
+         * @param downloadMissingOnly true to only download missing objects, if false, re-download everything
          * @return An outcome with the success flag as well as an error message in case of a failure.
          */
-        virtual AZ::Outcome<void, AZStd::string> RefreshGemRepo(const QString& repoUri) = 0;
+        virtual AZ::Outcome<void, AZStd::string> RefreshGemRepo(const QString& repoUri, bool downloadMissingOnly = false) = 0;
 
         /**
          * Refresh all gem repos in the current engine.
+         * @param downloadMissingOnly true to only download missing objects, if false, re-download everything
          * @return true on success, false on failure.
          */
-        virtual bool RefreshAllGemRepos() = 0;
+        virtual bool RefreshAllGemRepos(bool downloadMissingOnly = false) = 0;
 
         /**
          * Registers this gem repo with the current engine.
@@ -325,6 +332,15 @@ namespace O3DE::ProjectManager
          */
         virtual bool RemoveGemRepo(const QString& repoUri) = 0;
 
+        /**
+         * Enables or disables a remote repo.  The repo remains registered, but
+         * the objects contained within are no longer included in queries or
+         * available to download
+         * @param repoUri the absolute filesystem path or url to the gem repo.
+         * @return true on success, false on failure.
+         */
+        virtual bool SetRepoEnabled(const QString& repoUri, bool enabled) = 0;
+
         /**
          * Get all available gem repo infos. Gathers all repos registered with the engine.
          * @return A list of gem repo infos.
@@ -334,15 +350,18 @@ namespace O3DE::ProjectManager
         /**
          * Gathers all gem infos from the provided repo
          * @param repoUri the absolute filesystem path or url to the gem repo.
+         * @param enabledOnly Whether to only include enabled repos 
          * @return A list of gem infos.
          */
-        virtual AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForRepo(const QString& repoUri) = 0;
+        virtual AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForRepo(const QString& repoUri, bool enabledOnly = true) = 0;
 
         /**
          * Gathers all gem infos for all gems registered from repos.
+         * @param projectPath an optional project path to use for compatibility information
+         * @param enabledOnly Whether to only include enabled repos 
          * @return A list of gem infos.
          */
-        virtual AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForAllRepos() = 0;
+        virtual AZ::Outcome<QVector<GemInfo>, AZStd::string> GetGemInfosForAllRepos(const QString& projectPath = "", bool enabledOnly = true) = 0;
 
         /**
          * Downloads and registers a Gem.

+ 27 - 1
Code/Tools/ProjectManager/Source/ScreenWidget.h

@@ -10,6 +10,7 @@
 #if !defined(Q_MOC_RUN)
 #include <ScreenDefs.h>
 #include <ProjectInfo.h>
+#include <ScreensCtrl.h>
 
 #include <QWidget>
 #include <QStyleOption>
@@ -28,20 +29,24 @@ namespace O3DE::ProjectManager
             : QFrame(parent)
         {
         }
+
         ~ScreenWidget() = default;
 
         virtual ProjectManagerScreen GetScreenEnum()
         {
             return ProjectManagerScreen::Empty;
         }
+
         virtual bool IsReadyForNextScreen()
         {
             return true;
         }
+
         virtual bool IsTab()
         {
             return false;
         }
+
         virtual QString GetTabText()
         {
             return tr("Missing");
@@ -51,12 +56,33 @@ namespace O3DE::ProjectManager
         {
             return GetScreenEnum() == screen;
         }
+
         virtual void GoToScreen([[maybe_unused]] ProjectManagerScreen screen)
         {
         }
+
         virtual void Init()
         {
         }
+
+        ScreensCtrl* GetScreensCtrl(QObject* widget)
+        {
+            if (!widget)
+            {
+                return nullptr;
+            }
+
+            ScreensCtrl* screensCtrl = qobject_cast<ScreensCtrl*> (widget);
+            return screensCtrl ? screensCtrl : GetScreensCtrl(widget->parent());
+        }
+
+        //! Returns true if this screen is the current screen 
+        virtual bool IsCurrentScreen()
+        {
+            ScreensCtrl* screensCtrl = GetScreensCtrl(this);
+            return screensCtrl ? screensCtrl->GetCurrentScreen() == this : false;
+        }
+
         //! Notify this screen it is the current screen 
         virtual void NotifyCurrentScreen()
         {
@@ -69,7 +95,7 @@ namespace O3DE::ProjectManager
         void NotifyCurrentProject(const QString& projectPath);
         void NotifyBuildProject(const ProjectInfo& projectInfo);
         void NotifyProjectRemoved(const QString& projectPath);
-
+        void NotifyRemoteContentRefreshed();
     };
 
 } // namespace O3DE::ProjectManager

+ 1 - 0
Code/Tools/ProjectManager/Source/ScreensCtrl.h

@@ -39,6 +39,7 @@ namespace O3DE::ProjectManager
         void NotifyCurrentProject(const QString& projectPath);
         void NotifyBuildProject(const ProjectInfo& projectInfo);
         void NotifyProjectRemoved(const QString& projectPath);
+        void NotifyRemoteContentRefreshed();
 
     public slots:
         bool ChangeToScreen(ProjectManagerScreen screen);

+ 2 - 1
Code/Tools/ProjectManager/Source/TemplateInfo.cpp

@@ -17,6 +17,7 @@ namespace O3DE::ProjectManager
 
     bool TemplateInfo::IsValid() const
     {
-        return !m_path.isEmpty() && !m_name.isEmpty();
+        // remote templates may have empty paths until they are downloaded
+        return ((!m_isRemote && !m_path.isEmpty()) || m_isRemote) && !m_name.isEmpty();
     }
 } // namespace O3DE::ProjectManager

+ 8 - 2
Code/Tools/ProjectManager/Source/UpdateProjectCtrl.cpp

@@ -46,7 +46,6 @@ namespace O3DE::ProjectManager
         m_gemRepoScreen = new GemRepoScreen(this);
 
         connect(m_projectGemCatalogScreen, &ScreenWidget::ChangeScreenRequest, this, &UpdateProjectCtrl::OnChangeScreenRequest);
-        connect(m_gemRepoScreen, &GemRepoScreen::OnRefresh, m_projectGemCatalogScreen, &ProjectGemCatalogScreen::Refresh);
         connect(static_cast<ScreensCtrl*>(parent), &ScreensCtrl::NotifyProjectRemoved, m_projectGemCatalogScreen, &GemCatalogScreen::NotifyProjectRemoved);
 
         m_stack = new QStackedWidget(this);
@@ -123,18 +122,20 @@ namespace O3DE::ProjectManager
         if (screen == ProjectManagerScreen::GemRepos)
         {
             m_stack->setCurrentWidget(m_gemRepoScreen);
-            m_gemRepoScreen->Reinit();
+            m_gemRepoScreen->NotifyCurrentScreen();
             Update();
         }
         else if (screen == ProjectManagerScreen::ProjectGemCatalog)
         {
             m_projectGemCatalogScreen->ReinitForProject(m_projectInfo.m_path);
+            m_projectGemCatalogScreen->NotifyCurrentScreen();
             m_stack->setCurrentWidget(m_projectGemCatalogScreen);
             Update();
         }
         else if (screen == ProjectManagerScreen::UpdateProjectSettings)
         {
             m_stack->setCurrentWidget(m_updateSettingsScreen);
+            m_updateSettingsScreen->NotifyCurrentScreen();
             Update();
         }
         else
@@ -148,6 +149,7 @@ namespace O3DE::ProjectManager
         if (UpdateProjectSettings(true))
         {
             m_projectGemCatalogScreen->ReinitForProject(m_projectInfo.m_path);
+            m_projectGemCatalogScreen->NotifyCurrentScreen();
             m_stack->setCurrentWidget(m_projectGemCatalogScreen);
             Update();
         }
@@ -158,6 +160,10 @@ namespace O3DE::ProjectManager
         if (m_stack->currentIndex() > 0)
         {
             m_stack->setCurrentIndex(m_stack->currentIndex() - 1);
+            if (ScreenWidget* screenWidget = qobject_cast<ScreenWidget*>(m_stack->currentWidget()); screenWidget)
+            {
+                screenWidget->NotifyCurrentScreen();
+            }
             Update();
         }
         else

+ 2 - 0
Code/Tools/ProjectManager/project_manager_files.cmake

@@ -156,4 +156,6 @@ set(FILES
     Source/GemRepo/GemRepoListView.cpp
     Source/GemRepo/GemRepoModel.h
     Source/GemRepo/GemRepoModel.cpp
+    Source/GemRepo/GemRepoProxyModel.h
+    Source/GemRepo/GemRepoProxyModel.cpp
 )

+ 6 - 6
Code/Tools/ProjectManager/tests/MockPythonBindings.h

@@ -33,7 +33,7 @@ namespace O3DE::ProjectManager
         MOCK_METHOD2(UnregisterGem, AZ::Outcome<void, AZStd::string>(const QString&, const QString&));
 
         // Project
-        MOCK_METHOD3(CreateProject, AZ::Outcome<ProjectInfo>(const QString&, const ProjectInfo&, bool));
+        MOCK_METHOD3(CreateProject, AZ::Outcome<ProjectInfo, IPythonBindings::ErrorPair>(const QString&, const ProjectInfo&, bool));
         MOCK_METHOD1(GetProject, AZ::Outcome<ProjectInfo>(const QString&));
         MOCK_METHOD0(GetProjects, AZ::Outcome<QVector<ProjectInfo>>());
         MOCK_METHOD2(AddProject, DetailedOutcome(const QString&, bool));
@@ -47,17 +47,17 @@ namespace O3DE::ProjectManager
 
         // ProjectTemplate
         MOCK_METHOD0(GetProjectTemplates, AZ::Outcome<QVector<ProjectTemplateInfo>>());
-        MOCK_CONST_METHOD0(GetProjectTemplatesForAllRepos, AZ::Outcome<QVector<ProjectTemplateInfo>>());
+        MOCK_CONST_METHOD1(GetProjectTemplatesForAllRepos, AZ::Outcome<QVector<ProjectTemplateInfo>>(bool));
         MOCK_METHOD0(GetGemTemplates, AZ::Outcome<QVector<TemplateInfo>>());
 
         // Gem Repos
-        MOCK_METHOD1(RefreshGemRepo, AZ::Outcome<void, AZStd::string>(const QString&));
-        MOCK_METHOD0(RefreshAllGemRepos, bool());
+        MOCK_METHOD2(RefreshGemRepo, AZ::Outcome<void, AZStd::string>(const QString&, bool));
+        MOCK_METHOD1(RefreshAllGemRepos, bool(bool));
         MOCK_METHOD1(AddGemRepo, DetailedOutcome(const QString&));
         MOCK_METHOD1(RemoveGemRepo, bool(const QString&));
         MOCK_METHOD0(GetAllGemRepoInfos, AZ::Outcome<QVector<GemRepoInfo>, AZStd::string>());
-        MOCK_METHOD1(GetGemInfosForRepo, AZ::Outcome<QVector<GemInfo>, AZStd::string>(const QString&));
-        MOCK_METHOD0(GetGemInfosForAllRepos, AZ::Outcome<QVector<GemInfo>, AZStd::string>());
+        MOCK_METHOD2(GetGemInfosForRepo, AZ::Outcome<QVector<GemInfo>, AZStd::string>(const QString&, bool));
+        MOCK_METHOD2(GetGemInfosForAllRepos, AZ::Outcome<QVector<GemInfo>, AZStd::string>(const QString&, bool));
         MOCK_METHOD4(DownloadGem, DetailedOutcome(const QString&, const QString&, std::function<void(int, int)>, bool));
         MOCK_METHOD0(CancelDownload, void());
         MOCK_METHOD2(IsGemUpdateAvaliable, bool(const QString&, const QString&));

+ 10 - 0
cmake/Platform/Common/Install_common.cmake

@@ -508,6 +508,16 @@ function(ly_setup_cmake_install)
         list(JOIN relative_templates ",\n${indent}" O3DE_INSTALL_TEMPLATES)
     endif()
 
+    # Read the "repos" key from the source engine.json
+    o3de_read_json_array(engine_repos ${LY_ROOT_FOLDER}/engine.json "repos")
+    if(engine_repos)
+        foreach(repo_uri ${engine_repos})
+            list(APPEND repos "\"${repo_uri}\"")
+        endforeach()
+        list(SORT repos CASE INSENSITIVE)
+        list(JOIN repos ",\n${indent}" O3DE_INSTALL_REPOS)
+    endif()
+
     # Read the "api_versions" key from the source engine.json
     o3de_read_json_key(O3DE_INSTALL_API_VERSIONS ${LY_ROOT_FOLDER}/engine.json "api_versions")
 

+ 1 - 1
cmake/Subdirectories.cmake

@@ -215,7 +215,7 @@ function(resolve_gem_dependencies object_type object_path)
         )
 
     if(O3DE_CLI_RESULT)
-        message(WARNING "Dependecy resolution failed\n  Error: ${O3DE_CLI_OUT}")
+        message(WARNING "Dependecy resolution failed.\n  If needed, set the O3DE_DISABLE_GEM_DEPENDENCY_RESOLUTION variable to bypass dependency resolution.\n  Error: ${O3DE_CLI_OUT}")
         return()
     endif()
 

+ 1 - 0
cmake/install/engine.json.in

@@ -10,5 +10,6 @@
     "external_subdirectories": [@O3DE_INSTALL_EXTERNAL_SUBDIRS@],
     "gem_names": [@O3DE_INSTALL_ENGINE_GEMS@],
     "projects": [@O3DE_INSTALL_PROJECTS@],
+    "repos": [@O3DE_INSTALL_REPOS@],
     "templates": [@O3DE_INSTALL_TEMPLATES@]
 }

+ 3 - 0
engine.json

@@ -125,5 +125,8 @@
         "Templates/PythonToolGem",
         "Templates/ScriptCanvasNode",
         "Templates/UnifiedMultiplayerGem"
+    ],
+    "repos": [
+        "https://canonical.o3de.org"
     ]
 }

+ 6 - 1
scripts/o3de.py

@@ -34,7 +34,8 @@ def add_args(parser: argparse.ArgumentParser) -> None:
     sys.path.insert(0, str(o3de_package_dir))
     from o3de import engine_properties, engine_template, gem_properties, \
         global_project, register, print_registration, get_registration, \
-        enable_gem, disable_gem, project_properties, sha256, download, export_project
+        enable_gem, disable_gem, project_properties, sha256, download, \
+        export_project, repo
     # Remove the temporarily added path
     sys.path = sys.path[1:]
 
@@ -76,6 +77,10 @@ def add_args(parser: argparse.ArgumentParser) -> None:
 
     # export_project
     export_project.add_args(subparsers)
+    
+    # repo
+    repo.add_args(subparsers)
+
 
 if __name__ == "__main__":
     # parse the command line args

+ 9 - 1
scripts/o3de/o3de/compatibility.py

@@ -75,7 +75,15 @@ def get_most_compatible_project_engine_path(project_path:pathlib.Path,
             engine_version = '0.0.0'
 
         if has_compatible_version([project_engine], engine_name, engine_version):
-            if not most_compatible_engine_path:
+
+            # prefer the engine this project or template resides in if it is compatible
+            if pathlib.PurePath(project_path).is_relative_to(pathlib.PurePath(engine_path)):
+                most_compatible_engine_path = pathlib.Path(engine_path)
+                most_compatible_engine_version = Version(engine_version)
+                # don't consider other engines, if a user wants to override 
+                # they can set the engine_path or engine in user/project.json 
+                break
+            elif not most_compatible_engine_path:
                 most_compatible_engine_path = pathlib.Path(engine_path)
                 most_compatible_engine_version = Version(engine_version)
             elif Version(engine_version) > most_compatible_engine_version:

+ 85 - 40
scripts/o3de/o3de/download.py

@@ -23,7 +23,7 @@ import zipfile
 from datetime import datetime
 from tempfile import TemporaryDirectory
 
-from o3de import manifest, repo, utils, validation, register
+from o3de import manifest, repo, utils, register
 
 logger = logging.getLogger('o3de.download')
 logging.basicConfig(format=utils.LOG_FORMAT)
@@ -85,16 +85,13 @@ def get_downloadable(engine_name: str = None,
                      gem_name: str = None,
                      template_name: str = None,
                      restricted_name: str = None) -> dict or None:
-    json_data = manifest.load_o3de_manifest()
-    try:
-        o3de_object_uris = json_data['repos']
-    except KeyError as key_err:
-        logger.error(f'Unable to load repos from o3de manifest: {str(key_err)}')
+    repos = manifest.get_manifest_repos()
+    if not repos:
         return None
 
     manifest_json = 'repo.json'
-    search_func = lambda manifest_json_data: repo.search_repo(manifest_json_data, engine_name, project_name, gem_name, template_name)
-    return repo.search_o3de_object(manifest_json, o3de_object_uris, search_func)
+    search_func = lambda manifest_json_data: repo.search_repo(manifest_json_data, engine_name, project_name, gem_name, template_name, restricted_name)
+    return repo.search_o3de_object(manifest_json, repos, search_func)
 
 def replace_parent_with_subdir(parent_path:pathlib.Path, subdir_path:pathlib.Path):
     with TemporaryDirectory() as tmp_dir:
@@ -111,9 +108,11 @@ def replace_parent_with_subdir(parent_path:pathlib.Path, subdir_path:pathlib.Pat
 
 def download_o3de_object(object_name: str, default_folder_name: str, dest_path: str or pathlib.Path,
                          object_type: str, downloadable_kwarg_key, skip_auto_register: bool,
-                         force_overwrite: bool, download_progress_callback = None) -> int:
+                         force_overwrite: bool, download_progress_callback = None,
+                         use_source_control: bool = False) -> int:
 
-    download_path = manifest.get_o3de_cache_folder() / default_folder_name / object_name
+    object_name_without_version_specifier, version_specifier = utils.get_object_name_and_optional_version_specifier(object_name)
+    download_path = manifest.get_o3de_cache_folder() / default_folder_name / object_name_without_version_specifier
     download_path.mkdir(parents=True, exist_ok=True)
     download_zip_path = download_path / f'{object_type}.zip'
 
@@ -122,23 +121,49 @@ def download_o3de_object(object_name: str, default_folder_name: str, dest_path:
         logger.error(f'Downloadable o3de object {object_name} not found.')
         return 1
 
-    origin_uri = downloadable_object_data['origin_uri']
+    if use_source_control:
+        origin_uri = downloadable_object_data.get('source_control_uri')
+        if not origin_uri:
+            logger.error(f"Tried to use source control to acquire {object_name} but the 'source_control_uri' is empty or missing.")
+            return 1
+    else:
+        origin_uri = downloadable_object_data.get('download_source_uri')
+        if not origin_uri:
+            # legacy repo.json schema used origin_uri instead of download_source_uri
+            origin_uri = downloadable_object_data.get('origin_uri')
+        
+        if not origin_uri:
+            logger.error(f"Cannot download remote object {object_name} because neither the 'download_source_uri' or 'origin_uri' are set.")
+            return 1
+
+    object_version = downloadable_object_data.get('version', '0.0.0')
+    if not object_version:
+        logger.warning(f"The 'version' value for {object_name} was empty, using '0.0.0'")
+        object_version = '0.0.0'
+
     parsed_uri = urllib.parse.urlparse(origin_uri)
 
     if not dest_path:
         dest_path = manifest.get_registered(default_folder=default_folder_name)
         dest_path = pathlib.Path(dest_path).resolve()
-        dest_path = dest_path / object_name
+        dest_path = dest_path / object_name_without_version_specifier / object_version
     else:
         dest_path = pathlib.Path(dest_path).resolve()
 
     # If we have a git link then we should clone to the given download path here otherwise download and extract the zip
     git_provider = utils.get_git_provider(parsed_uri)
     if git_provider:
-        clone_result = git_provider.clone_from_git(parsed_uri.geturl(), dest_path)
+        source_control_ref = downloadable_object_data.get('source_control_ref')
+        clone_result = git_provider.clone_from_git(parsed_uri.geturl(), dest_path, force_overwrite, source_control_ref)
         if clone_result:
-            logger.error(f'Could not clone {parsed_uri.geturl()}')
+            if source_control_ref:
+                logger.error(f"Could not clone '{parsed_uri.geturl()}' reference '{source_control_ref}'. Please verify the repository uri and reference are correct and accessible.")
+            else:
+                logger.error(f"Could not clone '{parsed_uri.geturl()}'. Please verify the repository uri and reference are correct and accessible.")
             return 1
+    elif use_source_control:
+        logger.error(f"Currently only git repositories are supported and the provided uri '{parsed_uri.geturl()}' does not have the '.git' extension ")
+        return 1
     else:
         download_zip_result = utils.download_zip_file(parsed_uri, download_zip_path, force_overwrite, object_name, download_progress_callback)
         if download_zip_result != 0:
@@ -163,7 +188,7 @@ def download_o3de_object(object_name: str, default_folder_name: str, dest_path:
                     logger.error(f'Could not remove existing destination path {dest_path}.')
                     return 1
 
-        dest_path.mkdir(exist_ok=True)
+        dest_path.mkdir(parents=True, exist_ok=True)
 
         # extract zip
         with zipfile.ZipFile(download_zip_path, 'r') as zip_file_ref:
@@ -198,7 +223,8 @@ def download_engine(engine_name: str,
                     dest_path: str or pathlib.Path,
                     skip_auto_register: bool,
                     force_overwrite: bool,
-                    download_progress_callback = None) -> int:
+                    download_progress_callback = None,
+                    use_source_control: bool = False) -> int:
     return download_o3de_object(engine_name,
                                 'engines',
                                 dest_path,
@@ -206,14 +232,16 @@ def download_engine(engine_name: str,
                                 'engine_name',
                                 skip_auto_register,
                                 force_overwrite,
-                                download_progress_callback)
+                                download_progress_callback,
+                                use_source_control)
 
 
 def download_project(project_name: str,
                      dest_path: str or pathlib.Path,
                      skip_auto_register: bool,
                      force_overwrite: bool,
-                     download_progress_callback = None) -> int:
+                     download_progress_callback = None,
+                     use_source_control: bool = False) -> int:
     return download_o3de_object(project_name,
                                 'projects',
                                 dest_path,
@@ -221,14 +249,16 @@ def download_project(project_name: str,
                                 'project_name',
                                 skip_auto_register,
                                 force_overwrite,
-                                download_progress_callback)
+                                download_progress_callback,
+                                use_source_control)
 
 
 def download_gem(gem_name: str,
                  dest_path: str or pathlib.Path,
                  skip_auto_register: bool,
                  force_overwrite: bool,
-                 download_progress_callback = None) -> int:
+                 download_progress_callback = None,
+                 use_source_control: bool = False) -> int:
     return download_o3de_object(gem_name,
                                 'gems',
                                 dest_path,
@@ -236,14 +266,16 @@ def download_gem(gem_name: str,
                                 'gem_name',
                                 skip_auto_register,
                                 force_overwrite,
-                                download_progress_callback)
+                                download_progress_callback,
+                                use_source_control)
 
 
 def download_template(template_name: str,
                       dest_path: str or pathlib.Path,
                       skip_auto_register: bool,
                       force_overwrite: bool,
-                      download_progress_callback = None) -> int:
+                      download_progress_callback = None,
+                      use_source_control: bool = False) -> int:
     return download_o3de_object(template_name,
                                 'templates',
                                 dest_path,
@@ -251,15 +283,16 @@ def download_template(template_name: str,
                                 'template_name',
                                 skip_auto_register,
                                 force_overwrite,
-                                download_progress_callback)
-
+                                download_progress_callback,
+                                use_source_control)
 
 
 def download_restricted(restricted_name: str,
                         dest_path: str or pathlib.Path,
                         skip_auto_register: bool,
                         force_overwrite: bool,
-                        download_progress_callback = None) -> int:
+                        download_progress_callback = None,
+                        use_source_control: bool = False) -> int:
     return download_o3de_object(restricted_name,
                                 'restricted',
                                 dest_path,
@@ -267,7 +300,8 @@ def download_restricted(restricted_name: str,
                                 'restricted_name',
                                 skip_auto_register,
                                 force_overwrite,
-                                download_progress_callback)
+                                download_progress_callback,
+                                use_source_control)
 
 def is_o3de_object_update_available(object_name: str, downloadable_kwarg_key, local_last_updated: str) -> bool:
     downloadable_object_data = get_downloadable(**{downloadable_kwarg_key : object_name})
@@ -316,22 +350,30 @@ def _run_download(args: argparse) -> int:
         return download_engine(args.engine_name,
                                args.dest_path,
                                args.skip_auto_register,
-                               args.force)
+                               args.force,
+                               download_progress_callback=None,
+                               use_source_control=args.use_source_control)
     elif args.project_name:
         return download_project(args.project_name,
                                 args.dest_path,
                                 args.skip_auto_register,
-                                args.force)
+                                args.force,
+                                download_progress_callback=None,
+                                use_source_control=args.use_source_control)
     elif args.gem_name:
         return download_gem(args.gem_name,
                             args.dest_path,
                             args.skip_auto_register,
-                            args.force)
+                            args.force,
+                            download_progress_callback=None,
+                            use_source_control=args.use_source_control)
     elif args.template_name:
         return download_template(args.template_name,
                                  args.dest_path,
                                  args.skip_auto_register,
-                                 args.force)
+                                 args.force,
+                                 download_progress_callback=None,
+                                 use_source_control=args.use_source_control)
 
     return 1
 
@@ -343,26 +385,29 @@ def add_parser_args(parser):
     :param parser: the caller passes an argparse parser like instance to this method
     """
     group = parser.add_mutually_exclusive_group(required=True)
-    group.add_argument('-e', '--engine-name', type=str, required=False,
+    group.add_argument('--engine-name', '-e', type=str, required=False,
                        help='Downloadable engine name.')
-    group.add_argument('-p', '--project-name', type=str, required=False,
-                       help='Downloadable project name.')
-    group.add_argument('-g', '--gem-name', type=str, required=False,
-                       help='Downloadable gem name.')
-    group.add_argument('-t', '--template-name', type=str, required=False,
-                       help='Downloadable template name.')
-    parser.add_argument('-dp', '--dest-path', type=str, required=False,
+    group.add_argument('--project-name', '-p', type=str, required=False,
+                       help='Downloadable project name with optional version specifier e.g. project==1.2.3\nIf no version specifier is provided, the most recent version will be downloaded.')
+    group.add_argument('--gem-name', '-g', type=str, required=False,
+                       help='Downloadable gem name with optional version specifier e.g. gem==1.2.3\nIf no version specifier is provided, the most recent version will be downloaded.')
+    group.add_argument('--template-name', '-t', type=str, required=False,
+                       help='Downloadable template name with optional version specifier e.g. template==1.2.3\nIf no version specifier is provided, the most recent version will be downloaded.')
+    parser.add_argument('--dest-path', '-dp', type=str, required=False,
                             default=None,
                             help='Optional destination folder to download into.'
                                  ' i.e. download --project-name "CustomProject" --dest-path "C:/projects"'
                                  ' will result in C:/projects/CustomProject'
                                  ' If blank will download to default object type folder')
-    parser.add_argument('-sar', '--skip-auto-register', action='store_true', required=False,
+    parser.add_argument('--skip-auto-register', '-sar', action='store_true', required=False,
                             default=False,
                             help = 'Skip the automatic registration of new object download')
-    parser.add_argument('-f', '--force', action='store_true', required=False,
+    parser.add_argument('--force', '-f', action='store_true', required=False,
                             default=False,
                             help = 'Force overwrite the current object')
+    parser.add_argument('--use-source-control', '--src', action='store_true', required=False,
+                            default=False,
+                            help = 'Acquire from source control instead of downloading a .zip archive.  Requires that the object has a valid source_control_uri.')
 
     parser.set_defaults(func=_run_download)
 

+ 74 - 0
scripts/o3de/o3de/git_utils.py

@@ -0,0 +1,74 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+"""
+This file contains utility functions for using GitHub
+"""
+
+import logging
+import pathlib
+from urllib.parse import ParseResult
+import subprocess
+
+from o3de import gitproviderinterface, utils
+
+LOG_FORMAT = '[%(levelname)s] %(name)s: %(message)s'
+
+logger = logging.getLogger('o3de.git_utils')
+logging.basicConfig(format=LOG_FORMAT)
+
+class GenericGitProvider(gitproviderinterface.GitProviderInterface):
+    def get_specific_file_uri(self, parsed_uri: ParseResult):
+        logger.warning(f"GenericGitProvider does not yet support retrieving individual files")
+        return parsed_uri
+
+    def clone_from_git(self, uri, download_path: pathlib.Path, force_overwrite: bool = False, ref: str = None) -> int:
+        """
+        :param uri: uniform resource identifier
+        :param download_path: location path on disk to download file
+        :param ref: optional source control reference (commit/branch/tag) 
+        """
+        if download_path.exists():
+            # check if the path is not empty
+            if any(download_path.iterdir()):
+                if not force_overwrite:
+                    logger.error(f'Cannot clone into non-empty folder {download_path}')
+                    return 1
+                else:
+                    try:
+                        utils.remove_dir_path(download_path)
+                    except OSError:
+                        logger.error(f'Could not remove existing download path {download_path}')
+                        return 1
+
+        params = ["git", "clone", uri, download_path.as_posix()]
+        try:
+            with subprocess.Popen(params, stdout=subprocess.PIPE) as proc:
+                print(proc.stdout.read())
+        except Exception as e:
+            logger.error(str(e))
+            return 1
+
+        if proc.returncode == 0 and ref:
+            params = ["git", "-C", download_path.as_posix(), "reset", "--hard", ref]
+            try:
+                with subprocess.Popen(params, stdout=subprocess.PIPE) as proc:
+                    print(proc.stdout.read())
+            except Exception as e:
+                logger.error(str(e))
+                return 1
+
+        return proc.returncode
+
+def get_generic_git_provider(parsed_uri: ParseResult) -> GenericGitProvider or None:
+    # the only requirement we have is one of the path components ends in .git
+    # this could be relaxed further 
+    if parsed_uri.netloc and parsed_uri.scheme and parsed_uri.path:
+        for element in parsed_uri.path.split('/'):
+            if element.strip().endswith(".git"):
+                return GenericGitProvider()
+    return None

+ 29 - 5
scripts/o3de/o3de/github_utils.py

@@ -13,10 +13,11 @@ import json
 import logging
 import pathlib
 import urllib.parse
+from urllib.parse import ParseResult
 import urllib.request
 import subprocess
 
-from o3de import gitproviderinterface
+from o3de import gitproviderinterface, utils
 
 LOG_FORMAT = '[%(levelname)s] %(name)s: %(message)s'
 
@@ -24,7 +25,7 @@ logger = logging.getLogger('o3de.github_utils')
 logging.basicConfig(format=LOG_FORMAT)
 
 class GitHubProvider(gitproviderinterface.GitProviderInterface):
-    def get_specific_file_uri(parsed_uri):
+    def get_specific_file_uri(self, parsed_uri: ParseResult):
         components = parsed_uri.path.split('/')
         components = [ele for ele in components if ele.strip()]
 
@@ -48,11 +49,25 @@ class GitHubProvider(gitproviderinterface.GitProviderInterface):
 
         return parsed_uri
 
-    def clone_from_git(uri, download_path: pathlib.Path) -> int:
+    def clone_from_git(self, uri: ParseResult, download_path: pathlib.Path, force_overwrite: bool = False, ref: str = None) -> int:
         """
         :param uri: uniform resource identifier
         :param download_path: location path on disk to download file
+        :param ref: optional source control reference (commit/branch/tag) 
         """
+        if download_path.exists():
+            # check if the path is not empty
+            if any(download_path.iterdir()):
+                if not force_overwrite:
+                    logger.error(f'Cannot clone into non-empty folder {download_path}')
+                    return 1
+                else:
+                    try:
+                        utils.remove_dir_path(download_path)
+                    except OSError:
+                        logger.error(f'Could not remove existing download path {download_path}')
+                        return 1
+
         params = ["git", "clone", uri, download_path.as_posix()]
         try:
             with subprocess.Popen(params, stdout=subprocess.PIPE) as proc:
@@ -61,9 +76,18 @@ class GitHubProvider(gitproviderinterface.GitProviderInterface):
             logger.error(str(e))
             return 1
 
+        if proc.returncode == 0 and ref:
+            params = ["git", "-C", download_path.as_posix(), "reset", "--hard", ref]
+            try:
+                with subprocess.Popen(params, stdout=subprocess.PIPE) as proc:
+                    print(proc.stdout.read())
+            except Exception as e:
+                logger.error(str(e))
+                return 1
+
         return proc.returncode
 
-def get_github_provider(parsed_uri) -> GitHubProvider or None:
+def get_github_provider(parsed_uri: ParseResult) -> GitHubProvider or None:
     if 'github.com' in parsed_uri.netloc:
         components = parsed_uri.path.split('/')
         components = [ele for ele in components if ele.strip()]
@@ -72,6 +96,6 @@ def get_github_provider(parsed_uri) -> GitHubProvider or None:
             return None
 
         if components[1].endswith(".git"):
-            return GitHubProvider
+            return GitHubProvider()
 
     return None

+ 9 - 6
scripts/o3de/o3de/gitproviderinterface.py

@@ -9,12 +9,13 @@
 This file contains the interface for the Git providers
 """
 
-import abc
 import pathlib
+from abc import ABC, abstractmethod
+from urllib.parse import ParseResult
 
-class GitProviderInterface(abc.ABC):
-    @abc.abstractmethod
-    def get_specific_file_uri(parsed_uri):
+class GitProviderInterface(ABC):
+    @abstractmethod
+    def get_specific_file_uri(self, parsed_uri: ParseResult):
         """
         Gets a uri that can be used to download a resource if the provide allows it, otherwise returns the unmodified uri
         :param uri: uniform resource identifier to get a downloadable link for
@@ -22,12 +23,14 @@ class GitProviderInterface(abc.ABC):
         """
         pass
 
-    @abc.abstractmethod
-    def clone_from_git(uri, download_path: pathlib.Path) -> int:
+    @abstractmethod
+    def clone_from_git(self, uri: ParseResult, download_path: pathlib.Path, force_overwrite: bool = False, ref: str = None) -> int:
         """
         Clones a git repository from a uri into a given folder
         :param uri: uniform resource identifier
         :param download_path: location path on disk to download file
+        :param force_overwrite: whether to force overwrite the contents that already exist on disk or not
+        :param ref: optional source control reference which can be a commit hash, branch, tag or other reference type
         :return: return code, 0 on success
         """
         pass

+ 75 - 8
scripts/o3de/o3de/manifest.py

@@ -260,9 +260,31 @@ def get_manifest_restricted() -> list:
     return json_data['restricted'] if 'restricted' in json_data else []
 
 
-def get_manifest_repos() -> list:
-    json_data = load_o3de_manifest()
-    return json_data['repos'] if 'repos' in json_data else []
+def get_manifest_repos(project_path: pathlib.Path = None) -> list:
+    repos = set()
+
+    if project_path:
+        project_json_data = get_project_json_data(project_path=project_path)
+        if project_json_data:
+            repos.update(set(project_json_data.get('repos', [])))
+
+        # if a project is provided we only want the repos from the project's engine
+        engine_path = get_project_engine_path(project_path=project_path)
+    else:
+        # no project path provided, use the current engine's repos
+        engine_path = get_this_engine_path()
+
+    if engine_path:
+        engine_json_data = get_engine_json_data(engine_path=engine_path)
+        if engine_json_data:
+            repos.update(set(engine_json_data.get('repos', [])))
+
+    manifest_json_data = load_o3de_manifest()
+    if manifest_json_data:
+        repos.update(set(manifest_json_data.get('repos', [])))
+
+
+    return list(repos)
 
 
 # engine.json queries
@@ -415,6 +437,10 @@ def get_project_enabled_gems(project_path: pathlib.Path, include_dependencies:bo
     gems listed in project.json and the deprecated enabled_gems.json
     """
     project_json_data = get_project_json_data(project_path=project_path)
+    if not project_json_data:
+        logger.error(f"Failed to get project json data for the project at '{project_path}'")
+        return None
+
     active_gem_names = project_json_data.get('gem_names',[])
     enabled_gems_file = get_enabled_gem_cmake_file(project_path=project_path)
     if enabled_gems_file and enabled_gems_file.is_file():
@@ -473,7 +499,7 @@ def get_project_enabled_gems(project_path: pathlib.Path, include_dependencies:bo
             gem_name = gem.gem_json_data['gem_name']
             gem_name_with_specifier = gem_names_with_version_specifiers.get(gem_name,gem_name)
             if gem_name_with_specifier in result or include_dependencies:
-                result[gem_name_with_specifier] = gem.gem_json_data['path'].as_posix()
+                result[gem_name_with_specifier] = gem.gem_json_data['path'].as_posix() if gem.gem_json_data['path'] else None
     else:
         # Likely there is no resolution because gems are missing or wrong version
         # Provide the paths for the gems that are available
@@ -1008,6 +1034,47 @@ def get_most_compatible_gem(gem_name: str,
 
 
 def get_most_compatible_object(object_name: str, 
+                              name_key: str, 
+                              objects: list) -> dict or None:
+    """
+    Looks for the most compatible object based on object_name which may contain a version specifier.
+    Example: o3de>=1.2.3
+
+    :param object_name: Name of the object with optional version specifier 
+    :param name_key: Object name key inside the object's json file e.g. 'engine_name' 
+    :param objects: List of object json data to consider 
+    :return the most compatible object json data dict or None
+    """
+    most_compatible_version = Version('0.0.0')
+    object_name, version_specifier = utils.get_object_name_and_optional_version_specifier(object_name)
+    most_compatible_object = None
+
+    def update_most_compatible(candidate_version:str, json_data:dict):
+        nonlocal most_compatible_object
+        nonlocal most_compatible_version
+
+        if not most_compatible_object:
+            most_compatible_object = json_data 
+            most_compatible_version = Version(candidate_version)
+        elif Version(candidate_version) > most_compatible_version:
+            most_compatible_object = json_data 
+            most_compatible_version = Version(candidate_version)
+
+    for json_data in objects:
+        candidate_name = json_data.get(name_key,'')
+        if candidate_name != object_name:
+            continue
+
+        candidate_version = json_data.get('version','0.0.0')
+        if version_specifier:
+            if compatibility.has_compatible_version([object_name + version_specifier], candidate_name, candidate_version):
+                update_most_compatible(candidate_version, json_data)
+        else:
+            update_most_compatible(candidate_version, json_data)
+
+    return most_compatible_object
+
+def get_most_compatible_object_path(object_name: str, 
                               object_typename: str, 
                               object_validator: callable, 
                               name_key: str, 
@@ -1021,7 +1088,7 @@ def get_most_compatible_object(object_name: str,
     :param object_validator: Validator to use for json file 
     :param name_key: Object name key inside the object's json file e.g. 'engine_name' 
     :param objects: List of paths to search
-    :param gem_json_data_by_name
+    :return the most compatible object path or None
     """
     matching_paths = deque()
     most_compatible_version = Version('0.0.0')
@@ -1092,10 +1159,10 @@ def get_registered(engine_name: str = None,
     json_data = load_o3de_manifest()
 
     if isinstance(engine_name, str):
-        return get_most_compatible_object(engine_name, 'engine', validation.valid_o3de_engine_json, 'engine_name', get_manifest_engines())
+        return get_most_compatible_object_path(engine_name, 'engine', validation.valid_o3de_engine_json, 'engine_name', get_manifest_engines())
 
     elif isinstance(project_name, str):
-        return get_most_compatible_object(project_name, 'project', validation.valid_o3de_project_json, 'project_name', get_all_projects())
+        return get_most_compatible_object_path(project_name, 'project', validation.valid_o3de_project_json, 'project_name', get_all_projects())
 
     elif isinstance(gem_name, str):
         gems = []
@@ -1112,7 +1179,7 @@ def get_registered(engine_name: str = None,
                 for registered_project_path in registered_project_paths:
                     gems.extend(get_all_gems(registered_project_path))
                 gems = list(dict.fromkeys(gems))
-        return get_most_compatible_object(gem_name, 'gem', validation.valid_o3de_gem_json, 'gem_name', gems)
+        return get_most_compatible_object_path(gem_name, 'gem', validation.valid_o3de_gem_json, 'gem_name', gems)
 
     elif isinstance(template_name, str):
         templates = []

+ 67 - 9
scripts/o3de/o3de/project_manager_interface.py

@@ -12,7 +12,7 @@ Contains functions for the project manager to call that gather data from o3de sc
 import logging
 import pathlib
 
-from o3de import manifest, utils, compatibility, enable_gem, register, project_properties
+from o3de import manifest, utils, compatibility, enable_gem, register, project_properties, repo
 
 logger = logging.getLogger('o3de.project_manager_interface')
 logging.basicConfig(format=utils.LOG_FORMAT)
@@ -309,6 +309,14 @@ def get_all_gem_infos(project_path: pathlib.Path or None) -> list:
     # because the project might be using a different engine than the one Project Manager is
     # running out of
     engine_path = manifest.get_project_engine_path(project_path=project_path) if project_path else manifest.get_this_engine_path() 
+    if not engine_path:
+        logger.error("Failed to get engine path for gem info retrieval")
+        return [] 
+
+    engine_json_data = manifest.get_engine_json_data(engine_path=engine_path)
+    if not engine_json_data:
+        logger.error("Failed to get engine json data for gem info retrieval")
+        return [] 
 
     # get all gem json data by path so we can use it for detecting engine and project gems
     # without re-opening and parsing gem.json files again
@@ -326,17 +334,27 @@ def get_all_gem_infos(project_path: pathlib.Path or None) -> list:
     utils.replace_dict_keys_with_value_key(all_gem_json_data, value_key='gem_name', replaced_key_name='path', place_values_in_list=True)
 
     # flatten into a single list
-    all_gem_json_data = [gem_json_data for gem_versions in all_gem_json_data.values() for gem_json_data in gem_versions]
+    all_gem_json_data_list = [gem_json_data for gem_versions in all_gem_json_data.values() for gem_json_data in gem_versions]
 
-    for i, gem_json_data in enumerate(all_gem_json_data):
+    for i, gem_json_data in enumerate(all_gem_json_data_list):
         if project_path:
             if gem_json_data['path'] in project_gem_paths:
-                all_gem_json_data[i]['project_gem'] = True
+                all_gem_json_data_list[i]['project_gem'] = True
         
         if gem_json_data['path'] in engine_gem_paths:
-            all_gem_json_data[i]['engine_gem'] = True
+            all_gem_json_data_list[i]['engine_gem'] = True
+        else:
+            # gather general incompatibility information
+            incompatible_engine =  compatibility.get_incompatible_objects_for_engine(gem_json_data, engine_json_data)
+            if incompatible_engine:
+                all_gem_json_data_list[i]['incompatible_engine'] = incompatible_engine
+
+            incompatible_gem_dependencies = compatibility.get_incompatible_gem_dependencies(gem_json_data, all_gem_json_data)
+            if incompatible_gem_dependencies:
+                all_gem_json_data_list[i]['incompatible_gem_dependencies'] = incompatible_gem_dependencies
+        
 
-    return all_gem_json_data
+    return all_gem_json_data_list
 
 def download_gem(gem_name: str, force_overwrite: bool = False, progress_callback = None):
     """
@@ -405,8 +423,7 @@ def get_gem_infos_from_repo(repo_uri: str) -> list:
     """
     return list()
 
-
-def get_gem_infos_from_all_repos() -> list:
+def get_gem_infos_from_all_repos(project_path:pathlib.Path = None, enabled_only:bool = True) -> list:
     """
         Call get_gem_json_paths_from_all_cached_repos
 
@@ -414,7 +431,42 @@ def get_gem_infos_from_all_repos() -> list:
 
         :return list of dicts containing gem infos.
     """
-    return list()
+    remote_gem_json_data_list = repo.get_gem_json_data_from_all_cached_repos(enabled_only)
+    if not remote_gem_json_data_list:
+        return list()
+
+    engine_path = manifest.get_project_engine_path(project_path=project_path) if project_path else manifest.get_this_engine_path() 
+    if not engine_path:
+        logger.error("Failed to get engine path for remote gem compatibility checks")
+        return list() 
+
+    engine_json_data = manifest.get_engine_json_data(engine_path=engine_path)
+    if not engine_json_data:
+        logger.error("Failed to get engine json data remote gem compatibility checks")
+        return list() 
+
+    # get all gem json data by path so we can use it for detecting engine and project gems
+    # without re-opening and parsing gem.json files again
+    all_gem_json_data = manifest.get_gems_json_data_by_path(engine_path=engine_path,
+                                                            project_path=project_path,
+                                                            include_engine_gems=True,
+                                                            include_manifest_gems=True)
+
+    # first add all the known gems together before checking compatibility and dependencies
+    for i, gem_json_data in enumerate(remote_gem_json_data_list):
+        all_gem_json_data[i] = gem_json_data
+    
+    # do general compatibility checks - dependency resolution is too slow for now
+    for i, gem_json_data in enumerate(remote_gem_json_data_list):
+        incompatible_engine =  compatibility.get_incompatible_objects_for_engine(gem_json_data, engine_json_data)
+        if incompatible_engine:
+            remote_gem_json_data_list[i]['incompatible_engine_dependencies'] = incompatible_engine
+
+        incompatible_gem_dependencies = compatibility.get_incompatible_gem_dependencies(gem_json_data, all_gem_json_data)
+        if incompatible_gem_dependencies:
+            remote_gem_json_data_list[i]['incompatible_gem_dependencies'] = incompatible_gem_dependencies
+
+    return remote_gem_json_data_list
 
 
 def refresh_gem_repo(repo_uri: str):
@@ -430,3 +482,9 @@ def refresh_all_gem_repos():
         Call refresh_repos
     """
     pass
+
+def set_repo_enabled(repo_uri: str, enabled: bool) -> int:
+    """
+        Call set_repo_enabled 
+    """
+    return repo.set_repo_enabled(repo_uri, enabled)

+ 299 - 140
scripts/o3de/o3de/repo.py

@@ -6,13 +6,14 @@
 #
 #
 
+import argparse
 import json
 import logging
 import pathlib
 import urllib.parse
 import urllib.request
 import hashlib
-from datetime import datetime
+from datetime import datetime, timezone
 from o3de import manifest, utils, validation
 
 logger = logging.getLogger('o3de.repo')
@@ -39,67 +40,69 @@ def get_repo_manifest_uri(repo_uri: str) -> str or None:
 
     return f'{repo_uri}/repo.json'
 
-def download_repo_manifest(manifest_uri: str) -> pathlib.Path or None:
+def repo_enabled(repo_json_data:dict) -> bool:
+    # unless explicitely disabled assume enabled for backwards compatibility
+    return repo_json_data.get('enabled', True)
+
+def repo_uri_enabled(repo_uri: str) -> bool:
+    repo_json_cache_file, _ = get_cache_file_uri(f'{repo_uri}/repo.json')
+
+    repo_json_data = manifest.get_json_data_file(repo_json_cache_file, "repo", validation.valid_o3de_repo_json)
+    if repo_json_data:
+        return repo_enabled(repo_json_data)
+
+    return False
+
+def download_repo_manifest(manifest_uri: str, force_overwrite: bool = True) -> pathlib.Path or None:
     cache_file, parsed_uri = get_cache_file_uri(manifest_uri)
 
     git_provider = utils.get_git_provider(parsed_uri)
     if git_provider:
         parsed_uri = git_provider.get_specific_file_uri(parsed_uri)
 
-    result = utils.download_file(parsed_uri, cache_file, True)
+    result = utils.download_file(parsed_uri, cache_file, force_overwrite)
 
     return cache_file if result == 0 else None
 
-def download_object_manifests(repo_data):
-    cache_folder = manifest.get_o3de_cache_folder()
-    # A repo may not contain all types of object.
-    manifest_download_list = []
-    try:
-        manifest_download_list.append((repo_data['engines'], 'engine.json'))
-    except KeyError:
-        pass
-    try:
-        manifest_download_list.append((repo_data['projects'], 'project.json'))
-    except KeyError:
-        pass
-    try:
-        manifest_download_list.append((repo_data['gems'], 'gem.json'))
-    except KeyError:
-        pass
-    try:
-        manifest_download_list.append((repo_data['templates'], 'template.json'))
-    except KeyError:
-        pass
-    try:
-        manifest_download_list.append((repo_data['restricted'], 'restricted.json'))
-    except KeyError:
-        pass
+def download_object_manifests(repo_data: dict, download_missing_files_only: bool = False):
+
+    if get_repo_schema_version(repo_data) == REPO_SCHEMA_VERSION_1_0_0:
+        # schema version 1.0.0 includes all json data in repo.json
+        return 0
+
+    repo_object_type_manifests = [
+        ('engines','engine.json'),
+        ('projects','project.json'),
+        ('gems','gem.json'),
+        ('templates','template.json'),
+        ('restricted','restricted.json')
+        ]
 
-    for o3de_object_uris, manifest_json in manifest_download_list:
-        for o3de_object_uri in o3de_object_uris:
-            manifest_json_uri = f'{o3de_object_uri}/{manifest_json}'
+    for key, manifest_json_filename in repo_object_type_manifests:
+        for o3de_object_uri in repo_data.get(key, []):
+            manifest_json_uri = f'{o3de_object_uri}/{manifest_json_filename}'
             cache_file, parsed_uri = get_cache_file_uri(manifest_json_uri)
 
-            git_provider = utils.get_git_provider(parsed_uri)
-            if git_provider:
-                parsed_uri = git_provider.get_specific_file_uri(parsed_uri)
+            if not cache_file.exists() or not download_missing_files_only:
+                git_provider = utils.get_git_provider(parsed_uri)
+                if git_provider:
+                    parsed_uri = git_provider.get_specific_file_uri(parsed_uri)
 
-            download_file_result = utils.download_file(parsed_uri, cache_file, True)
-            if download_file_result != 0:
-                return download_file_result
+                download_file_result = utils.download_file(parsed_uri, cache_file, True)
+                if download_file_result != 0:
+                    return download_file_result
     return 0
 
 def get_repo_schema_version(repo_data: dict):
     return repo_data.get("$schemaVersion", REPO_IMPLICIT_SCHEMA_VERSION)
 
 def validate_remote_repo(repo_uri: str, validate_contained_objects: bool = False) -> bool:
-    manifest_uri = get_repo_manifest_uri(repo_uri)
 
+    manifest_uri = get_repo_manifest_uri(repo_uri)
     if not manifest_uri:
         return False
 
     cache_file = download_repo_manifest(manifest_uri)
-
     if not cache_file:
         logger.error(f'Could not download file at {manifest_uri}')
         return False
@@ -121,25 +124,27 @@ def validate_remote_repo(repo_uri: str, validate_contained_objects: bool = False
 
         if repo_schema_version == REPO_IMPLICIT_SCHEMA_VERSION:
             if download_object_manifests(repo_data) != 0:
+                # we don't issue an error message here because better error messaging is provided
+                # in the download functions themselves
                 return False
-            gem_set = get_gem_json_paths_from_cached_repo(repo_uri)
-            for gem_json in gem_set:
-                if not validation.valid_o3de_gem_json(gem_json):
-                    logger.error(f'Invalid gem JSON - {gem_json} could not be loaded or is missing required values')
+            cached_gems_json_data = get_gem_json_data_from_cached_repo(repo_uri)
+            for gem_json_data in cached_gems_json_data:
+                if not validation.valid_o3de_gem_json_data(gem_json_data):
+                    logger.error(f'Invalid gem JSON - {gem_json_data} is missing required values')
                     return False
-            project_set = get_project_json_paths_from_cached_repo(repo_uri)
-            for project_json in project_set:
-                if not validation.valid_o3de_project_json(project_json):
-                    logger.error(f'Invalid project JSON - {project_json} could not be loaded or is missing required values')
+            cached_project_json_data = get_project_json_data_from_cached_repo(repo_uri)
+            for project_json_data in cached_project_json_data:
+                if not validation.valid_o3de_project_json_data(project_json_data):
+                    logger.error(f'Invalid project JSON - {project_json_data} is missing required values')
                     return False
-            template_set = get_template_json_paths_from_cached_repo(repo_uri)
-            for template_json in template_set:
-                if not validation.valid_o3de_template_json(template_json):
-                    logger.error(f'Invalid template JSON - {template_json} could not be loaded or is missing required values')
+            cached_template_json_data = get_template_json_data_from_cached_repo(repo_uri)
+            for template_json_data in cached_template_json_data:
+                if not validation.valid_o3de_template_json_data(template_json_data):
+                    logger.error(f'Invalid template JSON - {template_json_data} is missing required values')
                     return False
                 
         elif repo_schema_version == REPO_SCHEMA_VERSION_1_0_0:
-            gem_list = repo_data.get("gems", [])
+            gem_list = repo_data.get("gems_data", [])
             for gem_json in gem_list:
                 if not validation.valid_o3de_gem_json_data(gem_json):
                     logger.error(f'Invalid gem JSON - {gem_json} is missing required values')
@@ -149,12 +154,11 @@ def validate_remote_repo(repo_uri: str, validate_contained_objects: bool = False
                 if "versions_data" in gem_json:
                     versions_data = gem_json["versions_data"]
                     for version in versions_data:
-                        if not all(key in version for key in ['last_updated', 'version']):
-                            logger.error("Invalid gem JSON - {gem_json} Both last_updated and version fields must be defined for each entry in the versions_data field")
+                        if not all(key in version for key in ['version']):
+                            logger.error("Invalid gem JSON - {gem_json} The version field must be defined for each entry in the versions_data field")
                             return False
                         
                         source_control_uri_defined = "source_control_uri" in version
-                        
                         download_source_uri_defined = "download_source_uri" in version
                         origin_uri_defined = "origin_uri" in version
                         
@@ -166,13 +170,13 @@ def validate_remote_repo(repo_uri: str, validate_contained_objects: bool = False
                             logger.error(f"Invalid gem JSON - {gem_json} At least one of source_control_uri or download_source_uri must be defined")
                             return False
 
-            project_list = repo_data.get("projects", [])
+            project_list = repo_data.get("projects_data", [])
             for project_json in project_list:
                 if not validation.valid_o3de_project_json_data(project_json):
                     logger.error(f'Invalid project JSON - {project_json} is missing required values')
                     return False                    
 
-            template_list = repo_data.get("templates", [])
+            template_list = repo_data.get("templates_data", [])
             for template_json in template_list:
                 if not validation.valid_o3de_template_json_data(template_json):
                     logger.error(f'Invalid template JSON - {template_json} is missing required values')
@@ -181,12 +185,12 @@ def validate_remote_repo(repo_uri: str, validate_contained_objects: bool = False
     return True
 
 def process_add_o3de_repo(file_name: str or pathlib.Path,
-                          repo_set: set) -> int:
+                          repo_set: set,
+                          download_missing_files_only: bool = False) -> int:
     file_name = pathlib.Path(file_name).resolve()
     if not validation.valid_o3de_repo_json(file_name):
         logger.error(f'Repository JSON {file_name} could not be loaded or is missing required values')
         return 1
-    cache_folder = manifest.get_o3de_cache_folder()
 
     repo_data = {}
     with file_name.open('r') as f:
@@ -198,141 +202,192 @@ def process_add_o3de_repo(file_name: str or pathlib.Path,
 
     with file_name.open('w') as f:
         try:
-            time_now = datetime.now()
-            time_str = time_now.strftime('%d/%m/%Y %H:%M')
-            repo_data.update({'last_updated': time_str})
+            # write the ISO8601 format which includes UTC offset
+            # YYYY-MM-DDTHH:MM:SS.mmmmmmTZD  (e.g. 2012-03-29T10:05:45.12345+06:00)
+            # TZD (time zone designator may have + or - indicating how far ahead or 
+            # behind a time zone is from UTC)
+            # because we are writing out UTC dates, the TZD will always be +00:00
+            repo_data.update({'last_updated': datetime.now(timezone.utc).isoformat()})
             f.write(json.dumps(repo_data, indent=4) + '\n')
         except Exception as e:
             logger.error(f'{file_name} failed to save: {str(e)}')
             return 1
 
-    if download_object_manifests(repo_data) != 0:
+    if download_object_manifests(repo_data, download_missing_files_only) != 0:
         return 1
 
     # Having a repo is also optional
     repo_list = []
-    try:
-        repo_list.extend(repo_data['repos'])
-    except KeyError:
-        pass
-
+    repo_list.extend(repo_data.get('repos',[]))
     for repo in repo_list:
         if repo not in repo_set:
             repo_set.add(repo)
             repo_uri = f'{repo}/repo.json'
             cache_file, parsed_uri = get_cache_file_uri(repo_uri)
             
-            download_file_result = utils.download_file(parsed_uri, cache_file, True)
-            if download_file_result != 0:
-                return download_file_result
+            if not cache_file.is_file() or not download_missing_files_only:
+                download_file_result = utils.download_file(parsed_uri, cache_file, True)
+                if download_file_result != 0:
+                    return download_file_result
 
-            return process_add_o3de_repo(cache_file, repo_set)
+            return process_add_o3de_repo(cache_file, repo_set, download_missing_files_only)
     return 0
 
+def get_object_versions_json_data(remote_object_list:list, required_json_key:str = None, required_json_value:str = None) -> list:
+    """
+    Convert a list of remote objects that may have 'versions_data', into a list
+    of object json data with a separate entry for every entry in 'versions_data'
+    or a single entry for every remote object that has no 'versions_data' entries
+    :param remote_object_list The list of remote object json data
+    :param required_json_key Optional required json key to look for in each object
+    :param required_json_value Optional required value if required json key is specified
+    """
+    object_json_data_list = []
+    for remote_object_json_data in remote_object_list:
+        if required_json_key and remote_object_json_data.get(required_json_key, '') != required_json_value:
+            continue
+
+        versions_data = remote_object_json_data.pop('versions_data', None)
+        if versions_data:
+            for version_json_data in versions_data:
+                object_json_data_list.append(remote_object_json_data | version_json_data)
+        else:
+            object_json_data_list.append(remote_object_json_data)
 
-def get_object_json_paths_from_cached_repo(repo_uri: str, repo_key: str, object_manifest_filename: str) -> set:
+    return object_json_data_list
+
+def get_object_json_data_from_cached_repo(repo_uri: str, repo_key: str, object_typename: str, object_validator, enabled_only = True) -> list:
     url = f'{repo_uri}/repo.json'
     cache_file, _ = get_cache_file_uri(url)
 
-    o3de_object_set = set()
+    o3de_object_json_data = list()
 
     file_name = pathlib.Path(cache_file).resolve()
     if not file_name.is_file():
-        logger.error(f'Could not find cached repository json file for {repo_uri}. Try refreshing the repository.')
-        return o3de_object_set
+        logger.info(f'Could not find cached repository json file for {repo_uri}, attempting to download')
+
+        # attempt to download the missing repo.json
+        cache_file = download_repo_manifest(url)
+        file_name = pathlib.Path(cache_file).resolve()
+        if not file_name.is_file():
+            logger.error(f'Could not download the repository json file from {repo_uri}')
+            return list() 
 
     with file_name.open('r') as f:
         try:
             repo_data = json.load(f)
         except json.JSONDecodeError as e:
             logger.error(f'{file_name} failed to load: {str(e)}')
-            return o3de_object_set
+            return list()
 
-        # Get list of objects, then add all json paths to the list if they exist in the cache
-        repo_objects = []
-        try:
-            repo_objects.append((repo_data[repo_key], object_manifest_filename + '.json'))
-        except KeyError:
-            pass
-
-        for o3de_object_uris, manifest_json in repo_objects:
-            for o3de_object_uri in o3de_object_uris:
-                manifest_json_uri = f'{o3de_object_uri}/{manifest_json}'
-                cache_object_json_filepath, _ = get_cache_file_uri(manifest_json_uri)
-                
-                if cache_object_json_filepath.is_file():
-                    o3de_object_set.add(cache_object_json_filepath)
-                else:
-                    logger.warning(f'Could not find cached {repo_key} json file {cache_object_json_filepath} for {o3de_object_uri} in repo {repo_uri}')
+        if enabled_only and not repo_enabled(repo_data):
+            return list()
+
+        repo_schema_version = get_repo_schema_version(repo_data)
+        if repo_schema_version == REPO_IMPLICIT_SCHEMA_VERSION:        
 
-    return o3de_object_set
+            # Get list of objects, then add all json paths to the list if they exist in the cache
+            repo_objects = []
+            try:
+                repo_objects.append((repo_data[repo_key], object_typename + '.json'))
+            except KeyError:
+                pass
+
+            for o3de_object_uris, manifest_json in repo_objects:
+                for o3de_object_uri in o3de_object_uris:
+                    manifest_json_uri = f'{o3de_object_uri}/{manifest_json}'
+                    cache_object_json_filepath, _ = get_cache_file_uri(manifest_json_uri)
+                    
+                    if not cache_object_json_filepath.is_file():
+                        # attempt to download the missing file
+                        cache_object_json_filepath = download_repo_manifest(manifest_json_uri)
+                        if not cache_object_json_filepath:
+                            logger.warning(f'Could not download the missing cached {repo_key} json file {cache_object_json_filepath} from {manifest_json_uri} in repo {repo_uri}')
+                            continue
+
+                    json_data = manifest.get_json_data_file(cache_object_json_filepath, object_typename, object_validator)
+                    # validation errors will be logged via the function above
+                    if json_data:
+                        o3de_object_json_data.append(json_data)
+
+        elif repo_schema_version == REPO_SCHEMA_VERSION_1_0_0:
+            # the new schema version appends _data to the repo key
+            # so it doesn't conflict with version 0.0.0 fields 
+            repo_key = repo_key if repo_key.endswith('_data') else (repo_key + '_data')
+            o3de_object_json_data.extend(get_object_versions_json_data(repo_data.get(repo_key,[])))
 
-def get_gem_json_paths_from_cached_repo(repo_uri: str) -> set:
-    return get_object_json_paths_from_cached_repo(repo_uri, 'gems', 'gem')
+    return o3de_object_json_data
 
-def get_gem_json_paths_from_all_cached_repos() -> set:
-    json_data = manifest.load_o3de_manifest()
-    gem_set = set()
+def get_gem_json_data_from_cached_repo(repo_uri: str, enabled_only: bool = True) -> list:
+    return get_object_json_data_from_cached_repo(repo_uri, 'gems', 'gem', validation.valid_o3de_gem_json, enabled_only)
 
-    for repo_uri in json_data.get('repos', []):
-        gem_set.update(get_gem_json_paths_from_cached_repo(repo_uri))
+def get_gem_json_data_from_all_cached_repos(enabled_only: bool = True) -> list:
+    gems_json_data = list()
 
-    return gem_set
+    for repo_uri in manifest.get_manifest_repos():
+        gems_json_data.extend(get_gem_json_data_from_cached_repo(repo_uri, enabled_only))
 
-def get_project_json_paths_from_cached_repo(repo_uri: str) -> set:
-    return get_object_json_paths_from_cached_repo(repo_uri, 'projects', 'project')
+    return gems_json_data
 
-def get_project_json_paths_from_all_cached_repos() -> set:
-    json_data = manifest.load_o3de_manifest()
-    project_set = set()
+def get_project_json_data_from_cached_repo(repo_uri: str, enabled_only: bool = True) -> list:
+    return get_object_json_data_from_cached_repo(repo_uri, 'projects', 'project', validation.valid_o3de_project_json, enabled_only)
 
-    for repo_uri in json_data.get('repos', []):
-        project_set.update(get_project_json_paths_from_cached_repo(repo_uri))
+def get_project_json_data_from_all_cached_repos(enabled_only: bool = True) -> list:
+    projects_json_data = list()
 
-    return project_set
+    for repo_uri in manifest.get_manifest_repos():
+        projects_json_data.extend(get_project_json_data_from_cached_repo(repo_uri, enabled_only))
 
-def get_template_json_paths_from_cached_repo(repo_uri: str) -> set:
-    return get_object_json_paths_from_cached_repo(repo_uri, 'templates', 'template')
+    return projects_json_data
 
-def get_template_json_paths_from_all_cached_repos() -> set:
-    json_data = manifest.load_o3de_manifest()
-    template_set = set()
+def get_template_json_data_from_cached_repo(repo_uri: str, enabled_only: bool = True) -> list:
+    return get_object_json_data_from_cached_repo(repo_uri, 'templates', 'template', validation.valid_o3de_template_json, enabled_only)
 
-    for repo_uri in json_data.get('repos', []):
-        template_set.update(get_template_json_paths_from_cached_repo(repo_uri))
+def get_template_json_data_from_all_cached_repos(enabled_only: bool = True) -> list:
+    templates_json_data = list()
 
-    return template_set
+    for repo_uri in manifest.get_manifest_repos():
+        templates_json_data.extend(get_template_json_data_from_cached_repo(repo_uri, enabled_only))
+
+    return templates_json_data
 
 def refresh_repo(repo_uri: str,
-                 repo_set: set = None) -> int:
+                 repo_set: set = None,
+                 download_missing_files_only: bool = False) -> int:
+
+    if not repo_uri_enabled(repo_uri):
+        logger.info(f'Not refreshing {repo_uri} repo because it is deactivated.')
+        return 0
+
     if not repo_set:
         repo_set = set()
 
     repo_uri = f'{repo_uri}/repo.json'
-    cache_file = download_repo_manifest(repo_uri)
-    if not cache_file:
-        logger.error(f'Repo json {repo_uri} could not download.')
-        return 1
+    cache_file, _ = get_cache_file_uri(repo_uri)
+    if not cache_file.is_file() or not download_missing_files_only:
+        cache_file = download_repo_manifest(repo_uri)
+        if not cache_file:
+            logger.error(f'Repo json {repo_uri} could not download.')
+            return 1
 
     if not validation.valid_o3de_repo_json(cache_file):
         logger.error(f'Repo json {repo_uri} is not valid.')
         cache_file.unlink()
         return 1
 
-    return process_add_o3de_repo(cache_file, repo_set)
+    return process_add_o3de_repo(cache_file, repo_set, download_missing_files_only)
 
-def refresh_repos() -> int:
-    json_data = manifest.load_o3de_manifest()
+def refresh_repos(download_missing_files_only: bool = False) -> int:
     result = 0
 
     # set will stop circular references
     repo_set = set()
 
-    for repo_uri in json_data.get('repos', []):
+    for repo_uri in manifest.get_manifest_repos():
         if repo_uri not in repo_set:
             repo_set.add(repo_uri)
 
-            last_failure = refresh_repo(repo_uri, repo_set)
+            last_failure = refresh_repo(repo_uri, repo_set, download_missing_files_only)
             if last_failure:
                 result = last_failure
 
@@ -345,7 +400,13 @@ def search_repo(manifest_json_data: dict,
                 gem_name: str = None,
                 template_name: str = None,
                 restricted_name: str = None) -> dict or None:
-    
+
+    # don't search this repo if it isn't enabled
+    if not repo_enabled(manifest_json_data):
+        return None
+
+    o3de_object = None
+
     repo_schema_version = get_repo_schema_version(manifest_json_data)
 
     if repo_schema_version == REPO_IMPLICIT_SCHEMA_VERSION:        
@@ -365,15 +426,15 @@ def search_repo(manifest_json_data: dict,
     elif repo_schema_version == REPO_SCHEMA_VERSION_1_0_0:
         #search for the o3de object from inside repos object 
         if isinstance(engine_name, str):
-            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'engines', 'engine_name', engine_name)
+            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'engines_data', 'engine_name', engine_name)
         elif isinstance(project_name, str):
-            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'projects', 'project_name', project_name)
+            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'projects_data', 'project_name', project_name)
         elif isinstance(gem_name, str):
-            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'gems', 'gem_name', gem_name)
+            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'gems_data', 'gem_name', gem_name)
         elif isinstance(template_name, str):
-            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'templates', 'template_name', template_name)
+            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'templates_data', 'template_name', template_name)
         elif isinstance(restricted_name, str):
-            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'restricted', 'restricted_name', restricted_name)
+            o3de_object = search_o3de_repo_for_object(manifest_json_data, 'restricted_data', 'restricted_name', restricted_name)
         else:
             return None
         
@@ -393,16 +454,34 @@ def search_repo(manifest_json_data: dict,
 
 
 def search_o3de_repo_for_object(repo_json_data: dict, manifest_attribute:str, target_json_key:str, target_name: str):
-    o3de_objects = repo_json_data.get(manifest_attribute, [])
-    for o3de_object in o3de_objects:
-        if o3de_object.get(target_json_key, '') == target_name:
-            return o3de_object
-    return None
+    remote_candidates = repo_json_data.get(manifest_attribute, [])
+
+    target_name_without_version_specifier, _ = utils.get_object_name_and_optional_version_specifier(target_name)
+
+    # merge all versioned data into a list of candidates
+    versioned_candidates = get_object_versions_json_data(remote_candidates, target_json_key, target_name_without_version_specifier)
+
+    return manifest.get_most_compatible_object(object_name=target_name, name_key=target_json_key, objects=versioned_candidates)
+
 
 def search_o3de_manifest_for_object(manifest_json_data: dict, manifest_attribute: str, target_manifest_json: str, target_json_key: str, target_name: str):
     o3de_object_uris = manifest_json_data.get(manifest_attribute, [])
-    search_func = lambda manifest_json_data: manifest_json_data if manifest_json_data.get(target_json_key, '') == target_name else None
-    return search_o3de_object(target_manifest_json, o3de_object_uris, search_func)
+
+    # load all the .json files and then find the most compatible object
+    candidates = []
+    for o3de_object_uri in o3de_object_uris:
+        manifest_uri = f'{o3de_object_uri}/{target_manifest_json}'
+        cache_file, _ = get_cache_file_uri(manifest_uri)
+        if cache_file.is_file():
+            with cache_file.open('r') as f:
+                try:
+                    manifest_json_data = json.load(f)
+                except json.JSONDecodeError as e:
+                    logger.warning(f'{cache_file} failed to load: {str(e)}')
+                else:
+                    candidates.append(manifest_json_data)
+
+    return manifest.get_most_compatible_object(object_name=target_name, name_key=target_json_key, objects=candidates)
 
 
 def search_o3de_object(manifest_json, o3de_object_uris, search_func):
@@ -422,3 +501,83 @@ def search_o3de_object(manifest_json, o3de_object_uris, search_func):
                     if result_json_data:
                         return result_json_data
     return None
+
+def set_repo_enabled(repo_uri:str, enabled:bool) -> int:
+    repo_uri = f'{repo_uri}/repo.json'
+
+    # avoid downloading if the file already exists and is valid
+    repo_json_cache_file, _ = get_cache_file_uri(repo_uri)
+    repo_json_data = manifest.get_json_data_file(repo_json_cache_file, "repo", validation.valid_o3de_repo_json)
+    if not repo_json_data:
+        # attempt to download the repo.json 
+        repo_json_cache_file = download_repo_manifest(repo_uri)
+        if not repo_json_cache_file.is_file():
+            logger.error(f'{repo_json_cache_file} could not be downloaded')
+            return 1
+
+        repo_json_data = manifest.get_json_data_file(repo_json_cache_file, "repo", validation.valid_o3de_repo_json)
+        if not repo_json_data:
+            logger.error(f'Repository JSON {repo_json_cache_file} could not be loaded or is missing required values')
+            repo_json_cache_file.unlink()
+            return 1
+
+    with repo_json_cache_file.open('w') as f:
+        try:
+            # write the ISO8601 format which includes UTC offset
+            # YYYY-MM-DDTHH:MM:SS.mmmmmmTZD  (e.g. 2012-03-29T10:05:45.12345+06:00)
+            # TZD (time zone designator may have + or - indicating how far ahead or 
+            # behind a time zone is from UTC)
+            # because we are writing out UTC dates, the TZD will always be +00:00
+            repo_json_data.update({'last_updated': datetime.now(timezone.utc).isoformat()})
+            repo_json_data.update({'enabled': enabled})
+            f.write(json.dumps(repo_json_data, indent=4) + '\n')
+        except Exception as e:
+            logger.error(f'{repo_json_cache_file} failed to save: {str(e)}')
+            return 1
+
+    return 0
+
+
+def _run_repo(args: argparse) -> int:
+    if args.refresh_repo:
+        return refresh_repo(args.refresh_repo)
+    elif args.refresh_all_repos:
+        return refresh_repos()
+    elif args.activate_repo:
+        return set_repo_enabled(args.activate_repo, True)
+    elif args.deactivate_repo:
+        return set_repo_enabled(args.deactivate_repo, False)
+
+    return 1 
+    
+
+def add_parser_args(parser):
+    """
+    add_parser_args is called to add arguments to each command such that it can be
+    invoked locally or added by a central python file.
+    Ex. Directly run from this file alone with: python print_registration.py --engine-projects
+    :param parser: the caller passes an argparse parser like instance to this method
+    """
+    group = parser.add_mutually_exclusive_group(required=False)
+    group.add_argument('-ar', '--activate-repo', type=str, required=False,
+                       help='Activate the specified remote repository, allowing searching and downloading of objects in it')
+    group.add_argument('-dr', '--deactivate-repo', type=str, required=False,
+                       help='Deactivate the specified remote repository, preventing searching or downloading any objects in it')
+    group.add_argument('-r', '--refresh-repo', type=str, required=False,
+                       help='Fetch the latest meta data the specified remote repository')
+    group.add_argument('-ra', '--refresh-all-repos', action='store_true', required=False, default=False,
+                       help='Fetch the latest meta data from all known remote repository')
+
+    parser.set_defaults(func=_run_repo)
+
+
+def add_args(subparsers) -> None:
+    """
+    add_args is called to add subparsers arguments to each command such that it can be
+    a central python file such as o3de.py.
+    It can be run from the o3de.py script as follows
+    call add_args and execute: python o3de.py repo --refresh https://path/to/remote/repo
+
+    :param subparsers: the caller instantiates subparsers and passes it in here
+    """
+    add_parser_args(subparsers.add_parser('repo'))

+ 44 - 19
scripts/o3de/o3de/utils.py

@@ -13,11 +13,14 @@ import importlib.util
 import logging
 import os
 import pathlib
+import stat
+
 import re
 import shutil
 import subprocess
 import sys
 import urllib.request
+from urllib.parse import ParseResult
 import uuid
 import zipfile
 from packaging.version import Version
@@ -229,6 +232,22 @@ def copyfileobj(fsrc, fdst, callback, length=0):
             return 1
     return 0
 
+def remove_dir_path(path:pathlib.Path):
+    """
+    Helper function to delete a folder, ignoring all errors if possible
+    :param path: The Path to the folder to delete
+    """
+    if path.exists() and path.is_dir():
+        files_to_delete = []
+        for root, dirs, files in os.walk(path):
+            for file in files:
+                files_to_delete.append(os.path.join(root, file))
+        for file in files_to_delete:
+            os.chmod(file, stat.S_IWRITE)
+            os.remove(file)
+
+        shutil.rmtree(path.resolve(), ignore_errors=True)
+
 
 def validate_identifier(identifier: str) -> bool:
     """
@@ -326,16 +345,23 @@ def backup_folder(folder: str or pathlib.Path) -> None:
                 renamed = True
 
 
-def get_git_provider(parsed_uri):
+def get_git_provider(parsed_uri: ParseResult):
     """
     Returns a git provider if one exists given the passed uri
     :param parsed_uri: uniform resource identifier of a possible git repository
     :return: A git provider implementation providing functions to get infomration about or clone a repository, see gitproviderinterface
     """
-    return github_utils.get_github_provider(parsed_uri)
+    # check for providers with unique APIs first
+    git_provider = github_utils.get_github_provider(parsed_uri)
+
+    if not git_provider:
+        # fallback to generic git provider
+        git_provider = git_utils.get_generic_git_provider(parsed_uri)
 
+    return git_provider
 
-def download_file(parsed_uri, download_path: pathlib.Path, force_overwrite: bool = False, object_name: str = "", download_progress_callback = None) -> int:
+
+def download_file(parsed_uri: ParseResult, download_path: pathlib.Path, force_overwrite: bool = False, object_name: str = "", download_progress_callback = None) -> int:
     """
     Download file
     :param parsed_uri: uniform resource identifier to zip file to download
@@ -344,25 +370,16 @@ def download_file(parsed_uri, download_path: pathlib.Path, force_overwrite: bool
     :param object_name: name of the object being downloaded
     :param download_progress_callback: callback called with the download progress as a percentage, returns true to request to cancel the download
     """
-    file_exists = False
-    if download_path.is_file():
-        if not force_overwrite:
-            file_exists = True
-        else:
-            try:
-                os.unlink(download_path)
-            except OSError:
-                logger.error(f'Could not remove existing download path {download_path}.')
-                return 1
+    file_exists = download_path.is_file()
 
     if parsed_uri.scheme in ['http', 'https', 'ftp', 'ftps']:
         try:
             current_request = urllib.request.Request(parsed_uri.geturl())
             resume_position = 0
-            if not force_overwrite:
-                if file_exists:
-                    resume_position = os.path.getsize(download_path)
-                    current_request.add_header("If-Range", "bytes=%d-" % resume_position)
+            if file_exists and not force_overwrite:
+                resume_position = os.path.getsize(download_path)
+                current_request.add_header("If-Range", "bytes=%d-" % resume_position)
+
             with urllib.request.urlopen(current_request) as s:
                 download_file_size = int(s.headers.get('content-length',0))
 
@@ -378,15 +395,23 @@ def download_file(parsed_uri, download_path: pathlib.Path, force_overwrite: bool
                 else:
                     logger.error(f'HTTP status {e.code} opening {parsed_uri.geturl()}')
                     return 1
+                
+                # remove the file only after we have a response from the server and something to replace it with
+                if file_exists and force_overwrite:
+                    try:
+                        os.unlink(download_path)
+                    except OSError:
+                        logger.error(f'Could not remove existing download path {download_path}.')
+                        return 1
 
                 def print_progress(downloaded, total_size):
                     end_ch = '\r'
                     if total_size == 0 or downloaded > total_size:
-                        print(f'Downloading {object_name} - {downloaded} bytes')
+                        print(f'Downloading {object_name if object_name else parsed_uri.geturl()} - {downloaded} bytes')
                     else:
                         if downloaded == total_size:
                             end_ch = '\n'
-                        print(f'Downloading {object_name} - {downloaded} of {total_size} bytes - {(downloaded/total_size)*100:.2f}%', end=end_ch)
+                        print(f'Downloading {object_name if object_name else parsed_uri.geturl()} - {downloaded} of {total_size} bytes - {(downloaded/total_size)*100:.2f}%', end=end_ch)
 
                 if download_progress_callback == None:
                     download_progress_callback = print_progress

+ 219 - 21
scripts/o3de/tests/test_download.py

@@ -10,10 +10,12 @@ import json
 import pytest
 import pathlib
 import urllib.request
+from urllib.parse import ParseResult
 from unittest.mock import patch, MagicMock, mock_open
 
-from o3de import manifest, download, utils
+from o3de import manifest, download, utils, git_utils 
 
+TEST_DEFAULT_GEMS_FOLDER = "C:/Users/testuser/O3DE/Gems"
 TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
 {
     "o3de_manifest_name": "testuser",
@@ -67,6 +69,52 @@ TEST_O3DE_REPO_WITH_OBJECTS_JSON_PAYLOAD = '''
 }
 '''
 
+TEST_O3DE_REPO_WITH_OBJECTS_JSON_PAYLOAD_VERSION_1_0_0 = '''
+{
+    "$schemaVersion": "1.0.0",
+    "repo_name": "Test Repo",
+    "origin": "Test Repo Origin",
+    "gems":[],
+    "gems_data": [
+        {
+            "gem_name": "TestRemoteVersionedGem",
+            "license": "Apache-2.0 Or MIT",
+            "origin": "Test Creator",
+            "repo_uri": "https://o3derepo.org",
+            "source_control_uri":"https://github.com/o3de/o3de-extras.git",
+            "last_updated": "2022-01-01 14:00:00",
+            "type": "Asset",
+            "summary": "A test downloadable gem.",
+            "canonical_tags": [
+                "Gem"
+            ],
+            "user_tags": [],
+            "icon_path": "preview.png",
+            "requirements": "",
+            "documentation_url": "",
+            "dependencies": [],
+            "versions_data": [
+                {
+                    "version":"1.0.0",
+                    "download_source_uri": "https://o3derepo.org/TestGem/1.0.0.zip",
+                    "sha256": "cd89c508cad0e48e51806a9963d17a0f2f7196e26c79f45aa9ea3b435a2ceb6a",
+                    "source_control_ref": "release-1.0.0"
+                },
+                {
+                    "version":"2.0.0",
+                    "download_source_uri": "https://o3derepo.org/TestGem/2.0.0.zip",
+                    "sha256": "cd89c508cad0e48e51806a9963d17a0f2f7196e26c79f45aa9ea3b435a2ceb6a",
+                    "source_control_ref": "release-2.0.0"
+                }
+            ]
+        }
+    ],
+    "projects": [],
+    "engines": [],
+    "templates": []
+}
+'''
+
 TEST_O3DE_REPO_GEM_FILE_NAME = 'a765db91484f0d963d4ba5c98161074df7cd87caf1340e6bc7cebdce1807c994.json'
 TEST_O3DE_REPO_GEM_JSON_PAYLOAD = '''
 {
@@ -89,6 +137,56 @@ TEST_O3DE_REPO_GEM_JSON_PAYLOAD = '''
 }
 '''
 
+TEST_O3DE_REPO_GEM_JSON_PAYLOAD_VERSION_1_0_0 = '''
+{
+    "gem_name": "TestRemoteVersionedGem",
+    "version":"1.0.0",
+    "license": "Apache-2.0 Or MIT",
+    "origin": "Test Creator",
+    "repo_uri": "https://o3derepo.org",
+    "source_control_uri":"https://github.com/o3de/o3de-extras.git",
+    "download_source_uri": "https://o3derepo.org/TestGem/1.0.0.zip",
+    "sha256": "cd89c508cad0e48e51806a9963d17a0f2f7196e26c79f45aa9ea3b435a2ceb6a",
+    "source_control_ref": "release-1.0.0",
+    "last_updated": "2022-01-01 14:00:00",
+    "type": "Asset",
+    "summary": "A test downloadable gem.",
+    "canonical_tags": [
+        "Gem"
+    ],
+    "user_tags": [],
+    "icon_path": "preview.png",
+    "requirements": "",
+    "documentation_url": "",
+    "dependencies": []
+}
+'''
+
+TEST_O3DE_REPO_GEM_JSON_PAYLOAD_VERSION_2_0_0 = '''
+{
+    "gem_name": "TestRemoteVersionedGem",
+    "version":"2.0.0",
+    "download_source_uri": "https://o3derepo.org/TestGem/2.0.0.zip",
+    "sha256": "cd89c508cad0e48e51806a9963d17a0f2f7196e26c79f45aa9ea3b435a2ceb6a",
+    "source_control_ref": "release-2.0.0",
+    "license": "Apache-2.0 Or MIT",
+    "origin": "Test Creator",
+    "repo_uri": "https://o3derepo.org",
+    "source_control_uri":"https://github.com/o3de/o3de-extras.git",
+    "last_updated": "2022-01-01 14:00:00",
+    "type": "Asset",
+    "summary": "A test downloadable gem.",
+    "canonical_tags": [
+        "Gem"
+    ],
+    "user_tags": [],
+    "icon_path": "preview.png",
+    "requirements": "",
+    "documentation_url": "",
+    "dependencies": []
+}
+'''
+
 TEST_O3DE_REPO_GEM_WITH_HASH_JSON_PAYLOAD = '''
 {
     "gem_name": "TestGem",
@@ -218,49 +316,68 @@ class TestObjectDownload:
         'http://o3derepo.org/TestGem/gem.zip',
         'http://o3derepo.org/TestProject/project.json',
         'http://o3derepo.org/TestTemplate/template.json',
-        'http://o3derecursiverepo.org/repo.json'
+        'http://o3derecursiverepo.org/repo.json',
+        'https://o3derepo.org/TestGem/1.0.0.zip',
+        'https://o3derepo.org/TestGem/2.0.0.zip'
     ]
 
-    @pytest.mark.parametrize("manifest_data, gem_name, expected_result, \
+    @pytest.mark.parametrize("test_name, manifest_data, gem_name, expected_result, expected_version, \
                              gem_data, zip_data,  \
-                             skip_auto_register, force_overwrite, contents_in_subdir, registration_expected", [
+                             skip_auto_register, force_overwrite, \
+                             contents_in_subdir, registration_expected", [
         # Remote and local gem tests
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0,
+        pytest.param("remote_gem_is_registered", TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0, None,
                      TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      False, True, False, True),
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0,
+        pytest.param("gem_with_contents_in_subdir_registered",TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0, None,
                      TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      False, True, True, True),
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestLocalGem', 0,
+        pytest.param("local_gem_is_registered",TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestLocalGem', 0, None,
                      TEST_O3DE_LOCAL_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      False, True, False, True),
-        pytest.param(TEST_O3DE_MANIFEST_EXISTING_GEM_JSON_PAYLOAD, 'TestGem', 1,
+        pytest.param("existing_gem_not_registered",TEST_O3DE_MANIFEST_EXISTING_GEM_JSON_PAYLOAD, 'TestGem', 1, None,
                      TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      False, False, False, False),
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0,
+        pytest.param("not_registered_when_skipped",TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0, None,
                      TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      True, True, False, False),
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'UnavailableGem', 1,
+        pytest.param("unavailable_gem_fails",TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'UnavailableGem', 1, None,
                      TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      False, True, False, False),
         # hashing tests
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0,
+        pytest.param("correct_hash_succeeds",TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 0, None,
                      TEST_O3DE_REPO_GEM_WITH_HASH_JSON_PAYLOAD, TEST_O3DE_ZIP_FILE_DATA, 
                      False, True, False, True),
-        pytest.param(TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 1,
+        pytest.param("wrong_hash_fails",TEST_O3DE_MANIFEST_JSON_PAYLOAD, 'TestGem', 1, None,
                      TEST_O3DE_REPO_GEM_WITH_HASH_JSON_PAYLOAD, TEST_O3DE_INCORRECT_FILE_DATA, 
                      False, True, False, False),
-        # subdir test
+
+        # versions
+        pytest.param("versioned_remote_gem_1.0.0_registered", TEST_O3DE_MANIFEST_JSON_PAYLOAD, 
+                     'TestRemoteVersionedGem==1.0.0', 0, '1.0.0',
+                     TEST_O3DE_REPO_GEM_JSON_PAYLOAD_VERSION_1_0_0, TEST_O3DE_ZIP_FILE_DATA, 
+                     False, True, False, True),
+        pytest.param("versioned_remote_gem_2.0.0_registered", TEST_O3DE_MANIFEST_JSON_PAYLOAD, 
+                     'TestRemoteVersionedGem==2.0.0', 0, '2.0.0',
+                     TEST_O3DE_REPO_GEM_JSON_PAYLOAD_VERSION_2_0_0, TEST_O3DE_ZIP_FILE_DATA, 
+                     False, True, False, True),
+        pytest.param("missing_versioned_remote_gem_fails", TEST_O3DE_MANIFEST_JSON_PAYLOAD, 
+                     'TestRemoteVersionedGem==3.0.0', 1, None,
+                     '', '', 
+                     False, True, False, False),
     ])
-    def test_download_gem(self, manifest_data, gem_name, expected_result, gem_data, zip_data, 
-                          skip_auto_register, force_overwrite, contents_in_subdir, registration_expected):
+    def test_download_gem(self, test_name, manifest_data, gem_name, expected_result, expected_version, 
+                          gem_data, zip_data, skip_auto_register, force_overwrite, 
+                          contents_in_subdir, registration_expected):
         self.o3de_manifest_data = json.loads(manifest_data)
         self.subdir_moved = False
+        self.download_callback_called = False
         self.created_files.clear()
+
         # add pre existing files
-        self.created_files.append('C:/Users/testuser/.o3de/cache/3fb160cdfde8b32864335e71a9b7a0519591f3080d2a06e7ca10f830e0cb7a54.json')
-        self.created_files.append('C:/Users/testuser/.o3de/cache/a765db91484f0d963d4ba5c98161074df7cd87caf1340e6bc7cebdce1807c994.json')
-        self.created_files.append('C:/Users/testuser/.o3de/cache/8758b5acace49baf89ba5d36c1c214f10f8e47cd198096d1ae6b016b23b0833d.json')
+        self.created_files.append(f'C:/Users/testuser/.o3de/cache/{TEST_O3DE_REPO_FILE_NAME}')
+        self.created_files.append(f'C:/Users/testuser/.o3de/cache/{TEST_O3DE_REPO_GEM_FILE_NAME}')
+        self.created_files.append(f'C:/Users/testuser/.o3de/cache/{TEST_O3DE_LOCAL_REPO_GEM_FILE_NAME}')
         self.created_files.append('C:/Users/testuser/.o3de/cache/Gems/TestGem/gem.zip')
         self.created_files.append('C:/localrepo/TestLocalGem/gem.zip')
 
@@ -299,7 +416,10 @@ class TestObjectDownload:
         def mocked_open(path, mode = '', *args, **kwargs):
             file_data = zip_data.encode('utf-8')
             if pathlib.Path(path).name == TEST_O3DE_REPO_FILE_NAME:
-                file_data = TEST_O3DE_REPO_WITH_OBJECTS_JSON_PAYLOAD
+                if expected_version:
+                    file_data = TEST_O3DE_REPO_WITH_OBJECTS_JSON_PAYLOAD_VERSION_1_0_0
+                else:
+                    file_data = TEST_O3DE_REPO_WITH_OBJECTS_JSON_PAYLOAD
             elif pathlib.Path(path).name == TEST_O3DE_REPO_GEM_FILE_NAME or \
                 pathlib.Path(path).name == TEST_O3DE_LOCAL_REPO_GEM_FILE_NAME or \
                 pathlib.Path(path).name == 'gem.json':
@@ -332,8 +452,8 @@ class TestObjectDownload:
             if mocked_isfile(origin):
                 self.created_files.append(dest)
 
-        download_callback_called = False
         def download_callback(downloaded, total_size):
+            #nonlocal download_callback_called
             download_callback_called = True
 
         def get_project_json_data(project_name: str = None,
@@ -342,7 +462,10 @@ class TestObjectDownload:
             return json.loads(TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD)
 
         def get_gem_json_data(gem_path: pathlib.Path, project_path: pathlib.Path):
-            return json.loads(TEST_O3DE_REPO_GEM_JSON_PAYLOAD)
+            if pathlib.PurePath(gem_path) == pathlib.PurePath(self.extracted_gem_path):
+                return json.loads(self.extracted_gem_json)
+            else:
+                return json.loads(TEST_O3DE_REPO_GEM_JSON_PAYLOAD)
 
         def get_engine_json_data(engine_name:str = None, engine_path:pathlib.Path = None):
             return json.loads(TEST_O3DE_REPO_ENGINE_JSON_PAYLOAD)
@@ -377,6 +500,81 @@ class TestObjectDownload:
             if contents_in_subdir:
                 assert self.subdir_moved
 
+    @pytest.mark.parametrize("test_name, gem_name, expected_ref, expected_registered_path, expected_result",
+    [
+        pytest.param("clone_default_uses_latest", 'TestRemoteVersionedGem', "release-2.0.0",
+                     pathlib.PurePath(f"{TEST_DEFAULT_GEMS_FOLDER}/TestRemoteVersionedGem/2.0.0"),
+                     0),
+        pytest.param("clone_1.0.0_gets_correct_ref", 'TestRemoteVersionedGem==1.0.0', "release-1.0.0",
+                     pathlib.PurePath(f"{TEST_DEFAULT_GEMS_FOLDER}/TestRemoteVersionedGem/1.0.0"),
+                     0),
+        pytest.param("clone_2.0.0_gets_correct_ref", 'TestRemoteVersionedGem==2.0.0', "release-2.0.0",
+                     pathlib.PurePath(f"{TEST_DEFAULT_GEMS_FOLDER}/TestRemoteVersionedGem/2.0.0"),
+                     0),
+        pytest.param("clone_3.0.0_fails", 'TestRemoteVersionedGem==3.0.0', None,
+                     None,
+                     1),
+    ])
+    def test_clone_gem(self, test_name, gem_name, expected_ref, expected_registered_path, expected_result):
+        self.created_files.clear()
+
+        self.registered_path = None
+        self.source_control_ref = None
+
+        # add pre existing file for repo
+        self.created_files.append(f'C:/Users/testuser/.o3de/cache/{TEST_O3DE_REPO_FILE_NAME}')
+
+        def load_o3de_manifest(manifest_path: pathlib.Path = None) -> dict:
+            return json.loads(TEST_O3DE_MANIFEST_JSON_PAYLOAD)
+
+        def mocked_open(path, mode = '', *args, **kwargs):
+            if pathlib.Path(path).name == TEST_O3DE_REPO_FILE_NAME:
+                file_data = TEST_O3DE_REPO_WITH_OBJECTS_JSON_PAYLOAD_VERSION_1_0_0
+            else:
+                return None
+            mockedopen = mock_open(mock=MagicMock(), read_data=file_data)
+            file_obj = mockedopen(self, *args, **kwargs)
+            file_obj.open = mocked_open
+            return file_obj
+
+        def mocked_isfile(path):
+            matches = [pathlib.Path(x).name for x in self.created_files if pathlib.Path(x).name == pathlib.Path(path).name]
+            if len(matches) != 0:
+                return True
+            else:
+                return False
+
+        def clone_from_git(uri:ParseResult, download_path: pathlib.Path, force_overwrite: bool = False, ref: str = None) -> int:
+            self.source_control_ref = ref
+            return 0
+
+        def get_git_provider(parsed_uri:ParseResult):
+            git_provider_mock = git_utils.GenericGitProvider()
+            git_provider_mock.clone_from_git = MagicMock(side_effect=clone_from_git)
+            return git_provider_mock
+
+        def register(gem_path: pathlib.Path = None):
+            self.registered_path = gem_path
+            return 0
+
+        with patch('o3de.manifest.load_o3de_manifest', side_effect=load_o3de_manifest) as _1,\
+                patch('pathlib.Path.resolve', new=self.resolve) as pathlib_is_resolve_mock,\
+                patch('o3de.manifest.get_o3de_cache_folder', return_value=pathlib.Path("Cache")) as get_o3de_cache_folder_patch, \
+                patch('o3de.register.register', side_effect=register) as register_patch, \
+                patch('o3de.utils.get_git_provider', side_effect=get_git_provider) as git_git_provider_patch, \
+                patch('o3de.utils.find_ancestor_dir_containing_file', return_value=None) as _3, \
+                patch('pathlib.Path.mkdir') as _7, \
+                patch('pathlib.Path.open', mocked_open) as _5, \
+                patch('pathlib.Path.is_file', mocked_isfile) as _6, \
+                patch('pathlib.Path.unlink') as _8, \
+                patch('os.unlink') as _13, \
+                patch('os.path.getsize', return_value=64) as _14:
+
+            result = download.download_gem(gem_name, '', skip_auto_register=False, force_overwrite=False, download_progress_callback=None, use_source_control=True)
+            assert result == expected_result
+            if expected_result == 0:
+                assert pathlib.PurePath(self.registered_path) == expected_registered_path
+                assert self.source_control_ref == expected_ref
 
     @pytest.mark.parametrize("update_function, object_name, object_data, existing_time, update_available", [
                                  # Repo gem is newer

+ 21 - 9
scripts/o3de/tests/test_project_manager_interface.py

@@ -352,33 +352,45 @@ def test_edit_project_properties():
 # manifest interface
 def test_refresh_repo():
     sig = signature(repo.refresh_repo)
-    assert len(sig.parameters) >= 1
+    assert len(sig.parameters) >= 3
 
-    repo_uri = list(sig.parameters.values())[0]
-    assert repo_uri.name == 'repo_uri'
-    assert repo_uri.annotation == str
+    parameters = list(sig.parameters.values())
+
+    assert parameters[0].name == 'repo_uri'
+    assert parameters[0].annotation == str
+
+    assert parameters[1].name == 'repo_set'
+    assert parameters[1].annotation == set
+
+    assert parameters[2].name == 'download_missing_files_only'
+    assert parameters[2].annotation == bool
 
     assert sig.return_annotation == int
 
 def test_refresh_repos():
     sig = signature(repo.refresh_repos)
+    assert len(sig.parameters) >= 1
+    parameters = list(sig.parameters.values())
+
+    assert parameters[0].name == 'download_missing_files_only'
+    assert parameters[0].annotation == bool
 
     assert sig.return_annotation == int
 
-def test_get_gem_json_paths_from_cached_repo():
-    sig = signature(repo.get_gem_json_paths_from_cached_repo)
+def test_get_gem_json_data_from_cached_repo():
+    sig = signature(repo.get_gem_json_data_from_cached_repo)
     assert len(sig.parameters) >= 1
 
     repo_uri = list(sig.parameters.values())[0]
     assert repo_uri.name == 'repo_uri'
     assert repo_uri.annotation == str
 
-    assert sig.return_annotation == set
+    assert sig.return_annotation == list 
 
 def test_get_gem_json_paths_from_all_cached_repos():
-    sig = signature(repo.get_gem_json_paths_from_all_cached_repos)
+    sig = signature(repo.get_gem_json_data_from_all_cached_repos)
 
-    assert sig.return_annotation == set
+    assert sig.return_annotation == list
 
 # download interface
 def test_download_gem():

+ 216 - 56
scripts/o3de/tests/test_repo.py

@@ -65,27 +65,23 @@ TEST_O3DE_REPOB_JSON_PAYLOAD = '''
 }
 '''
 
-# This holds the repo 2.0.0 schema, which houses all relevant objects in file
+# This holds the repo 1.0.0 schema, which houses all relevant objects in file
 # A possible future field for the version data is download_prebuilt_uri, 
 # which indicates where to download dedicated binaries of a version of the O3DE object. 
 TEST_O3DE_REPO_JSON_VERSION_2_FILENAME = '3b14717bafd5a3bd768d3d0791a44998c3bd0fb2bfa1a7e2ee8bb1a39b04d631.json'
 TEST_O3DE_REPO_JSON_VERSION_2_PAYLOAD = '''
 {
     "repo_name": "testgem3",
-
     "origin": "Studios",
-
-    "$schemaVersion":"2.0.0",
-    
+    "$schemaVersion":"1.0.0",
     "repo_uri": "https://downloads.testgem3.com/o3de-repo",
-
     "summary": "Studios Repository for the testgem3 Gem.",
-
     "additional_info": "",
-
     "last_updated": "2023-01-19",
-
-    "gems": [{
+    "gems":[
+        "https://legacygemrepo.com"
+    ],
+    "gems_data": [{
         "gem_name": "testgem3",
         "display_name": "testgem3 2",
         "download_api": "HTTP",
@@ -107,31 +103,19 @@ TEST_O3DE_REPO_JSON_VERSION_2_PAYLOAD = '''
         "dependencies": [],
         "versions_data": [{
                 "origin_uri": "https://downloads.testgem3.com/o3de-repo/testgem3-2.15/O3DE_testgem3Gem_v2.0.0_Win64_Linux64_Mac64.zip",
-                "version": "2.0.0",
+                "version": "1.0.0",
                 "last_updated": "2021-03-09"
             },
             {
-                "display_name": "testgem3 2.15.4",
+                "display_name": "testgem3 2.0.0",
                 "download_source_uri": "https://downloads.testgem3.com/o3de-repo/testgem3-2.15/O3DE_testgem3Gem_v2.15.4_Win64_Linux64_Mac64.zip",
-                "version": "2.15.4",
+                "version": "2.0.0",
                 "last_updated": "2023-03-09"
-            },
-            {
-                "display_name": "testgem3 3.1.2",
-                "source_control_uri": "https://github.com/testgem3/O3DEtestgem3Plugin.git",
-                "version": "3.1.2",
-                "last_updated": "2025-02-19"
-            },
-            {
-                "display_name": "testgem3 3.2.0",
-                "origin_uri": "https://github.com/testgem3/O3DEtestgem3Plugin",
-                "version": "3.2.0",
-                "last_updated": "2025-02-19"
             }
         ]
     }],
-
-    "projects":[{
+    "project":[],
+    "projects_data":[{
             "project_name": "TestProject",
             "project_id": "{24114e69-306d-4de6-b3b4-4cb1a3eca58e}",
             "version" : "0.0.0",
@@ -159,8 +143,8 @@ TEST_O3DE_REPO_JSON_VERSION_2_PAYLOAD = '''
             ]
         }
     ],
-
-    "templates":[{
+    "templates":[],
+    "templates_data":[{
             "template_name": "TestTemplate",
             "license": "Apache-2.0 Or MIT",
             "origin": "Test Creator",
@@ -181,6 +165,60 @@ TEST_O3DE_REPO_JSON_VERSION_2_PAYLOAD = '''
 }
 '''
 
+TEST_O3DE_REPO_GEM3_JSON_VERSION_1_PAYLOAD = '''
+{
+    "gem_name": "testgem3",
+    "display_name": "testgem3 2",
+    "download_api": "HTTP",
+    "license": "Community",
+    "license_url": "https://www.testgem3.com/testgem3-community-license",
+    "origin": "Persistant Studios - testgem3.com",
+    "requirements": "Users will need to download testgem3 Editor from the <a href='https://www.testgem3.com/download/'>testgem3 Web Site</a> to edit/author effects.",
+    "documentation_url": "https://www.testgem3.com/docs/testgem3-v2/plugins/o3de-gem/",
+    "type": "Code",
+    "summary": "The testgem3 Gem provides real-time FX solution for particle effects.",
+    "canonical_tags": [
+        "Gem"
+    ],
+    "user_tags": [
+        "Particles",
+        "Simulation",
+        "SDK"
+    ],
+    "dependencies": [],
+    "origin_uri": "https://downloads.testgem3.com/o3de-repo/testgem3-2.15/O3DE_testgem3Gem_v2.0.0_Win64_Linux64_Mac64.zip",
+    "version": "1.0.0",
+    "last_updated": "2021-03-09"
+}
+'''
+
+TEST_O3DE_REPO_GEM3_JSON_VERSION_2_PAYLOAD = '''
+{
+    "gem_name": "testgem3",
+    "download_api": "HTTP",
+    "license": "Community",
+    "license_url": "https://www.testgem3.com/testgem3-community-license",
+    "origin": "Persistant Studios - testgem3.com",
+    "requirements": "Users will need to download testgem3 Editor from the <a href='https://www.testgem3.com/download/'>testgem3 Web Site</a> to edit/author effects.",
+    "documentation_url": "https://www.testgem3.com/docs/testgem3-v2/plugins/o3de-gem/",
+    "type": "Code",
+    "summary": "The testgem3 Gem provides real-time FX solution for particle effects.",
+    "canonical_tags": [
+        "Gem"
+    ],
+    "user_tags": [
+        "Particles",
+        "Simulation",
+        "SDK"
+    ],
+    "dependencies": [],
+    "display_name": "testgem3 2.0.0",
+    "download_source_uri": "https://downloads.testgem3.com/o3de-repo/testgem3-2.15/O3DE_testgem3Gem_v2.15.4_Win64_Linux64_Mac64.zip",
+    "version": "2.0.0",
+    "last_updated": "2023-03-09"
+}
+'''
+
 TEST_O3DE_REPO_BROKEN_JSON_PAYLOAD = '''
 {
     "repo_name": "Test Repo",
@@ -227,6 +265,25 @@ TEST_O3DE_REPO_GEM_JSON_PAYLOAD = '''
     "dependencies": []
 }
 '''
+TEST_O3DE_REPO_GEM2_JSON_PAYLOAD = '''
+{
+    "gem_name": "TestGem2",
+    "license": "Apache-2.0 Or MIT",
+    "origin": "Test Creator",
+    "origin_uri": "http://o3derepo.org/TestGem2/gem.zip",
+    "repo_uri": "http://o3derepo.org",
+    "type": "Tool",
+    "summary": "A test downloadable gem.",
+    "canonical_tags": [
+        "Gem"
+    ],
+    "user_tags": [],
+    "icon_path": "preview.png",
+    "requirements": "",
+    "documentation_url": "",
+    "dependencies": []
+}
+'''
 
 TEST_O3DE_REPO_PROJECT_FILE_NAME = '233c6e449888b4dc1355b2bf668b91b53715888e6777a2791df0e7aec9d08989.json'
 TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD = '''
@@ -258,6 +315,35 @@ TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD = '''
     ]
 }
 '''
+TEST_O3DE_REPO_PROJECT2_JSON_PAYLOAD = '''
+{
+    "project_name": "TestProject2",
+    "project_id": "{04112c69-306d-4de6-b3b4-4cb1a3eca58e}",
+    "version" : "0.0.0",
+    "compatible_engines" : [
+        "o3de==1.2.3"
+    ],
+    "engine_api_dependencies" : [
+        "framework==1.2.3"
+    ],
+    "origin": "The primary repo for TestProject2 goes here: i.e. http://www.mydomain.com",
+    "license": "What license TestProject2 uses goes here: i.e. https://opensource.org/licenses/MIT",
+    "display_name": "TestProject",
+    "summary": "A short description of TestProject.",
+    "canonical_tags": [
+        "Project"
+    ],
+    "user_tags": [
+        "TestProject2"
+    ],
+    "icon_path": "preview.png",
+    "engine": "o3de==1.2.3",
+    "restricted_name": "projects",
+    "external_subdirectories": [
+        "TestGem"
+    ]
+}
+'''
 
 TEST_O3DE_REPO_TEMPLATE_FILE_NAME = '7802eae005ca1c023e14611ed63182299bf87e760708b4dba8086a134e309f3a.json'
 TEST_O3DE_REPO_TEMPLATE_JSON_PAYLOAD = '''
@@ -279,6 +365,25 @@ TEST_O3DE_REPO_TEMPLATE_JSON_PAYLOAD = '''
     "dependencies": []
 }
 '''
+TEST_O3DE_REPO_TEMPLATE2_JSON_PAYLOAD = '''
+{
+    "template_name": "TestTemplate2",
+    "license": "Apache-2.0 Or MIT",
+    "origin": "Test Creator",
+    "origin_uri": "http://o3derepo.org/TestTemplate/template2.zip",
+    "repo_uri": "http://o3derepo.org",
+    "type": "Tool",
+    "summary": "A test downloadable gem.",
+    "canonical_tags": [
+        "Template"
+    ],
+    "user_tags": [],
+    "icon_path": "preview.png",
+    "requirements": "",
+    "documentation_url": "",
+    "dependencies": []
+}
+'''
 
 @pytest.fixture(scope='class')
 def init_register_repo_data(request):
@@ -290,8 +395,11 @@ class TestRepos:
     valid_urls = [
         'http://o3derepo.org/repo.json',
         'http://o3derepo.org/TestGem/gem.json',
+        'http://o3derepo.org/TestGem2/gem.json',
         'http://o3derepo.org/TestProject/project.json',
+        'http://o3derepo.org/TestProject2/project.json',
         'http://o3derepo.org/TestTemplate/template.json',
+        'http://o3derepo.org/TestTemplate2/template.json',
         'http://o3derecursiverepo.org/repo.json'
     ]
 
@@ -399,53 +507,105 @@ class TestRepos:
             assert result == expected_result
             assert repo_path not in manifest.get_manifest_repos()
 
-    def test_get_object_list(self):
+    @pytest.mark.parametrize("test_name, repo_paths, expected_gems_json_data, expected_projects_json_data, expected_templates_json_data", [
+            pytest.param("repoA loads repoA objects", ['http://o3de.org/repoA'],  
+                         [TEST_O3DE_REPO_GEM_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_TEMPLATE_JSON_PAYLOAD]),
+            pytest.param("repoB loads repoB objects", ['http://o3de.org/repoB'],  
+                         [TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_REPO_GEM2_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_PROJECT2_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_TEMPLATE2_JSON_PAYLOAD]),
+            # TestGem is included twice because it is in both repositories
+            pytest.param("repoA and repoB loads all objects", ['http://o3de.org/repoA','http://o3de.org/repoB'],  
+                         [TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_REPO_GEM_JSON_PAYLOAD, TEST_O3DE_REPO_GEM2_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD, TEST_O3DE_REPO_PROJECT2_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_TEMPLATE_JSON_PAYLOAD, TEST_O3DE_REPO_TEMPLATE2_JSON_PAYLOAD]),
+            # RepoC contains a mix of objects with versions_data and objects without
+            pytest.param("repoC loads repoC objects", ['http://o3de.org/repoC'],  
+                         [TEST_O3DE_REPO_GEM3_JSON_VERSION_1_PAYLOAD, TEST_O3DE_REPO_GEM3_JSON_VERSION_2_PAYLOAD],
+                         [TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD],
+                         [TEST_O3DE_REPO_TEMPLATE_JSON_PAYLOAD])
+        ])
+    def test_get_object_json_data(self, test_name, repo_paths, expected_gems_json_data, 
+                                  expected_projects_json_data, expected_templates_json_data):
         self.o3de_manifest_data = json.loads(TEST_O3DE_MANIFEST_JSON_PAYLOAD)
-        self.o3de_manifest_data["repos"] = ["http://o3de.org/repoA", "http://o3de.org/repoB"]
-        self.created_files.clear()
+        self.o3de_manifest_data["repos"] = repo_paths
 
         def load_o3de_manifest(manifest_path: pathlib.Path = None) -> dict:
             return copy.deepcopy(self.o3de_manifest_data)
 
-        def save_o3de_manifest(manifest_data: dict, manifest_path: pathlib.Path = None) -> bool:
-            self.o3de_manifest_data = manifest_data
-            return True
-
         def mocked_open(path, mode, *args, **kwargs):
             file_data = bytes(0)
             if pathlib.Path(path).name == TEST_O3DE_REPOA_FILENAME:
                 file_data = TEST_O3DE_REPOA_JSON_PAYLOAD
             elif pathlib.Path(path).name == TEST_O3DE_REPOB_FILENAME:
                 file_data = TEST_O3DE_REPOB_JSON_PAYLOAD
+            elif pathlib.Path(path).name == TEST_O3DE_REPO_JSON_VERSION_2_FILENAME:
+                file_data = TEST_O3DE_REPO_JSON_VERSION_2_PAYLOAD
+
             mockedopen = mock_open(mock=MagicMock(), read_data=file_data)
             return mockedopen(self, *args, **kwargs)
 
+        def get_json_data_file(object_json: pathlib.Path,
+                            object_typename: str,
+                            object_validator: callable) -> dict or None:
+            if object_typename == 'gem':
+                if object_json == self.test_gem_cache_filename:
+                    return json.loads(TEST_O3DE_REPO_GEM_JSON_PAYLOAD)
+                if object_json == self.test_gem2_cache_filename:
+                    return json.loads(TEST_O3DE_REPO_GEM2_JSON_PAYLOAD)
+                else:
+                    return None
+            elif object_typename == 'project':
+                if object_json == self.test_project_cache_filename:
+                    return json.loads(TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD)
+                elif object_json == self.test_project2_cache_filename:
+                    return json.loads(TEST_O3DE_REPO_PROJECT2_JSON_PAYLOAD)
+                else:
+                    return None
+            elif object_typename == 'template':
+                if object_json == self.test_template_cache_filename:
+                    return json.loads(TEST_O3DE_REPO_TEMPLATE_JSON_PAYLOAD)
+                elif object_json == self.test_template2_cache_filename:
+                    return json.loads(TEST_O3DE_REPO_TEMPLATE2_JSON_PAYLOAD)
+                else:
+                    return None
+            else:
+                None
+
         with patch('o3de.manifest.load_o3de_manifest', side_effect=load_o3de_manifest) as _1,\
-                patch('o3de.manifest.save_o3de_manifest', side_effect=save_o3de_manifest) as _2, \
-                patch('pathlib.Path.open', mocked_open) as _3, \
-                patch('pathlib.Path.is_file', return_value=True) as _4:
-                    # Gems
-                    object_set = repo.get_gem_json_paths_from_cached_repo('http://o3de.org/repoA')
-                    assert len(object_set) == 1
-                    object_set = repo.get_gem_json_paths_from_all_cached_repos()
-                    assert len(object_set) == 2
-                    # Projects
-                    object_set = repo.get_project_json_paths_from_cached_repo('http://o3de.org/repoA')
-                    assert len(object_set) == 1
-                    object_set = repo.get_project_json_paths_from_all_cached_repos()
-                    assert len(object_set) == 2
-                    # Templates
-                    object_set = repo.get_template_json_paths_from_cached_repo('http://o3de.org/repoA')
-                    assert len(object_set) == 1
-                    object_set = repo.get_template_json_paths_from_all_cached_repos()
-                    assert len(object_set) == 2
-        assert True
+            patch('o3de.manifest.get_o3de_cache_folder', return_value=pathlib.Path('Cache')) as _2, \
+            patch('o3de.manifest.get_json_data_file', side_effect=get_json_data_file) as get_json_data_file_patch, \
+            patch('pathlib.Path.open', mocked_open) as _3, \
+            patch('pathlib.Path.is_file', return_value=True) as _4:
+
+            self.test_gem_cache_filename, _ = repo.get_cache_file_uri("http://o3derepo.org/TestGem/gem.json")
+            self.test_gem2_cache_filename, _ = repo.get_cache_file_uri("http://o3derepo.org/TestGem2/gem.json")
+            self.test_project_cache_filename, _ = repo.get_cache_file_uri("http://o3derepo.org/TestProject/project.json")
+            self.test_project2_cache_filename, _ = repo.get_cache_file_uri("http://o3derepo.org/TestProject2/project.json")
+            self.test_template_cache_filename, _ = repo.get_cache_file_uri("http://o3derepo.org/TestTemplate/template.json")
+            self.test_template2_cache_filename, _ = repo.get_cache_file_uri("http://o3derepo.org/TestTemplate2/template.json")
+
+            # Gems
+            gems_json_data = repo.get_gem_json_data_from_all_cached_repos()
+            expected_gems_json_list = [json.loads(data) for data in expected_gems_json_data]
+            assert all(json_data in expected_gems_json_list for json_data in gems_json_data) 
+            # Projects
+            projects_json_data = repo.get_project_json_data_from_all_cached_repos()
+            expected_projects_json_list = [json.loads(data) for data in expected_projects_json_data]
+            assert all(json_data in expected_projects_json_list for json_data in projects_json_data) 
+            # Templates
+            templates_json_data = repo.get_template_json_data_from_all_cached_repos()
+            expected_templates_json_list = [json.loads(data) for data in expected_templates_json_data]
+            assert all(json_data in expected_templates_json_list for json_data in templates_json_data) 
 
 
     @pytest.mark.parametrize("repo_uri, validate_objects", [
+        # tests with version schema 0.0.0
         pytest.param('http://o3de.org/repoA', False),
         pytest.param('http://o3de.org/repoA', True),
-        #tests with version schema 2.0.0
+        # tests with version schema 1.0.0
         pytest.param('http://o3de.org/repoC', False),
         pytest.param('http://o3de.org/repoC', True)
     ])