From 3b8cba0471233c827d41c3a4c8c18f6f55001918 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Wed, 2 Oct 2024 14:20:00 -0700 Subject: [PATCH] Git extension integration in Repository Management. (#3896) * Refining Repository Management's UI. (#3557) * Adding the Repository tool * WIP * View is set up. Test ata as well. * Aligning columns. Adding column spacing. * Reverting this change * Update tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Adressing comments * Revert "Reverting this change" This reverts commit 711dd787a7edee4d38340b649b64a5599555294c. * Moving to experimental * Disabling by default * Removing from experimental. Addressing comments. * Reverting to main * Update tools/RepositoryManagement/DevHome.RepositoryManagement/DevHome.RepositoryManagement.csproj Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Update tools/RepositoryManagement/DevHome.RepositoryManagement/Extensions/ServiceExtensions.cs Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Removing duplicate xaml code * Adding to the mermaid diagram * Got lost in the shuffle * Removing a refrence --------- Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Adding EF core, database project, and some usage. (#3674) * WIP * EF works. Can save data. * Can read and write. :) * Repos are added when cloned. * Putting save/load into a different class * Adding default values * Cleaning up names and using statements * More comments * Removing refrence to the public nuget * Restoring the nuget config * Adding a test. Better defining dates * Adding some tests * More comments. * Better path comparison. * Removing unused equals code * Adding more comments. Making new migrations * Moving this to experimental * WIP * Some telemetry and logging * THings build again. * Returning this back * I believe I am understanding this. * Can update * Almost done with open in FE * Try/Catching everything. Open in FE should be done. * Open in CMD works. :) * Re-adding changes lost due to merge * Getting saving straightened out. * WIP * Move Repository done. * Removing 1:1. Finishing RemoveFromList * Adding _items * Adding configuration file information to the repository table * Opening the configuration file location in FE * Deleting a repository. * Add to winget configuration file. * Cleaning up the code * Icon for a configuration file. UI updates when hiding a repo * All the buttons do something. * Removing 'local' * Making sure the test works * New test works * Another TODO * Fixing more things before PR. * Removing another un-used using. * MOre un used usings. * Another Todo * Anotehr TODO * Localized strings * WIP * Adding enhanced information if it can be found. * Commit, Filtering, and Sorting. * Fixing up the merge * Removing Move/Delete because of a bug in FileExplorerGitIntegration. * Adding a TODO * A small commit * Removing some unused usings * Update tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Update tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Update tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> * Adressing comments * Adressing comments * Addressing code quality comments * Update database/DevHome.Database/DatabaseModels/RepositoryManagement/Repository.cs Co-authored-by: Ryan Shepherd * Fixing some issues. * Added a TODO * Adding drop down to select a source control provider * Hide source control selection * FIxing indentation --------- Co-authored-by: Kristen Schau <47155823+krschau@users.noreply.github.com> Co-authored-by: Ryan Shepherd --- common/Models/ExperimentalFeature.cs | 2 +- .../DevHomeDatabaseContextEvent.cs | 38 ++ .../DevHomeDatabase/DevHomeDatabaseEvent.cs | 38 ++ .../EnhanceRepositoryErrorEvent.cs | 36 ++ .../RepositoryManagement/Repository.cs | 4 + .../DevHomeDatabaseContext.cs | 1 + ...240920200626_InitialMigration.Designer.cs} | 7 +- ....cs => 20240920200626_InitialMigration.cs} | 1 + .../DevHomeDatabaseContextModelSnapshot.cs | 5 + .../RepositoryManagementDataAccessService.cs | 56 ++- .../Strings/en-us/Resources.resw | 6 + src/App.xaml.cs | 3 - src/NavConfig.jsonc | 24 +- src/Services/PageService.cs | 4 +- test/Database/RepositoryTests.cs | 29 +- .../ViewModels/FileExplorerViewModel.cs | 12 +- .../DevHome.RepositoryManagement.csproj | 8 +- .../Extensions/ServiceExtensions.cs | 2 + ...epositoryManagementItemViewModelFactory.cs | 24 +- .../Models/Commit.cs | 24 ++ .../Services/RepositoryEnhancerService.cs | 128 ++++++ .../Strings/en-us/Resources.resw | 140 +++++++ .../RepositoryManagementItemViewModel.cs | 124 +++++- .../RepositoryManagementMainPageViewModel.cs | 388 ++++++++++++++++-- .../RepositoryManagementMainPageView.xaml | 206 +++++++--- .../RepositoryManagementMainPageView.xaml.cs | 2 + .../DevHome.SetupFlow.Common.csproj | 1 + .../Helpers/DscHelpers.cs | 32 +- .../DevHome.SetupFlow/Models/CloneRepoTask.cs | 36 +- 29 files changed, 1225 insertions(+), 156 deletions(-) create mode 100644 common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseContextEvent.cs create mode 100644 common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseEvent.cs create mode 100644 common/TelemetryEvents/RepositoryManagement/EnhanceRepositoryErrorEvent.cs rename database/DevHome.Database/Migrations/{20240919221355_InitialMigration.Designer.cs => 20240920200626_InitialMigration.Designer.cs} (89%) rename database/DevHome.Database/Migrations/{20240919221355_InitialMigration.cs => 20240920200626_InitialMigration.cs} (94%) create mode 100644 tools/RepositoryManagement/DevHome.RepositoryManagement/Models/Commit.cs create mode 100644 tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs diff --git a/common/Models/ExperimentalFeature.cs b/common/Models/ExperimentalFeature.cs index 6ee56923a1..9af6446d84 100644 --- a/common/Models/ExperimentalFeature.cs +++ b/common/Models/ExperimentalFeature.cs @@ -100,7 +100,7 @@ public async Task OnToggledAsync() [RelayCommand] public void Open() { - if (OpenPageKey != null) + if (!string.IsNullOrEmpty(OpenPageKey)) { Application.Current.GetService().NavigateTo(OpenPageKey, OpenPageParameter); } diff --git a/common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseContextEvent.cs b/common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseContextEvent.cs new file mode 100644 index 0000000000..918ef4d1b6 --- /dev/null +++ b/common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseContextEvent.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.Tracing; +using DevHome.Telemetry; +using Microsoft.Diagnostics.Telemetry.Internal; + +namespace DevHome.Common.TelemetryEvents.DevHomeDatabase; + +[EventData] +public class DevHomeDatabaseContextEvent : EventBase +{ + public override PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServicePerformance; + + public string Step { get; } = string.Empty; + + public int HResult { get; } + + public string ExceptionMessage { get; } = string.Empty; + + public DevHomeDatabaseContextEvent(string step) + { + Step = step; + } + + public DevHomeDatabaseContextEvent(string step, Exception ex) + { + Step = step; + HResult = ex.HResult; + ExceptionMessage = ex.Message; + } + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + // no sensitive strings to replace. + } +} diff --git a/common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseEvent.cs b/common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseEvent.cs new file mode 100644 index 0000000000..c2fe2caeb0 --- /dev/null +++ b/common/TelemetryEvents/DevHomeDatabase/DevHomeDatabaseEvent.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.Tracing; +using DevHome.Telemetry; +using Microsoft.Diagnostics.Telemetry.Internal; + +namespace DevHome.Common.TelemetryEvents.DevHomeDatabase; + +[EventData] +public class DevHomeDatabaseEvent : EventBase +{ + public override PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServicePerformance; + + public string Step { get; } = string.Empty; + + public int HResult { get; } + + public string ExceptionMessage { get; } = string.Empty; + + public DevHomeDatabaseEvent(string step) + { + Step = step; + } + + public DevHomeDatabaseEvent(string step, Exception ex) + { + Step = step; + HResult = ex.HResult; + ExceptionMessage = ex.Message; + } + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + // No sensitive strings to replace. + } +} diff --git a/common/TelemetryEvents/RepositoryManagement/EnhanceRepositoryErrorEvent.cs b/common/TelemetryEvents/RepositoryManagement/EnhanceRepositoryErrorEvent.cs new file mode 100644 index 0000000000..03a71616f9 --- /dev/null +++ b/common/TelemetryEvents/RepositoryManagement/EnhanceRepositoryErrorEvent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.Tracing; +using DevHome.Telemetry; +using Microsoft.Diagnostics.Telemetry.Internal; + +namespace DevHome.Common.TelemetryEvents.RepositoryManagement; + +[EventData] +public class EnhanceRepositoryErrorEvent : EventBase +{ + public override PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServicePerformance; + + public string Action { get; } = string.Empty; + + public int Hresult { get; } + + public string ErrorMessage { get; } = string.Empty; + + public string RepositoryName { get; } = string.Empty; + + public EnhanceRepositoryErrorEvent(string action, int hresult, string errorMessage, string repositoryName) + { + Action = action; + Hresult = hresult; + ErrorMessage = errorMessage; + RepositoryName = repositoryName; + } + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + // No sensitive strings to replace + } +} diff --git a/database/DevHome.Database/DatabaseModels/RepositoryManagement/Repository.cs b/database/DevHome.Database/DatabaseModels/RepositoryManagement/Repository.cs index bcd2c9894b..9b4fa2a78a 100644 --- a/database/DevHome.Database/DatabaseModels/RepositoryManagement/Repository.cs +++ b/database/DevHome.Database/DatabaseModels/RepositoryManagement/Repository.cs @@ -30,6 +30,10 @@ public class Repository // This causes any fluent API statements to get ignored. public string? RepositoryUri { get; set; } + public Guid? SourceControlClassId { get; set; } + + public bool HasAssignedSourceControlProvider => SourceControlClassId.GetValueOrDefault() != Guid.Empty; + public DateTime? CreatedUTCDate { get; set; } public DateTime? UpdatedUTCDate { get; set; } diff --git a/database/DevHome.Database/DevHomeDatabaseContext.cs b/database/DevHome.Database/DevHomeDatabaseContext.cs index c0b40eac88..73f8f7f82c 100644 --- a/database/DevHome.Database/DevHomeDatabaseContext.cs +++ b/database/DevHome.Database/DevHomeDatabaseContext.cs @@ -55,6 +55,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) repositoryEntity.Property(x => x.CreatedUTCDate).HasDefaultValueSql("datetime()"); repositoryEntity.Property(x => x.UpdatedUTCDate).HasDefaultValueSql("datetime()"); repositoryEntity.Property(x => x.RepositoryUri).HasDefaultValue(string.Empty); + repositoryEntity.Property(x => x.SourceControlClassId).HasDefaultValue(Guid.Empty); repositoryEntity.ToTable("Repository"); } } diff --git a/database/DevHome.Database/Migrations/20240919221355_InitialMigration.Designer.cs b/database/DevHome.Database/Migrations/20240920200626_InitialMigration.Designer.cs similarity index 89% rename from database/DevHome.Database/Migrations/20240919221355_InitialMigration.Designer.cs rename to database/DevHome.Database/Migrations/20240920200626_InitialMigration.Designer.cs index 4fa8d9c7d2..ed40e4028b 100644 --- a/database/DevHome.Database/Migrations/20240919221355_InitialMigration.Designer.cs +++ b/database/DevHome.Database/Migrations/20240920200626_InitialMigration.Designer.cs @@ -11,7 +11,7 @@ namespace DevHome.Database.Migrations { [DbContext(typeof(DevHomeDatabaseContext))] - [Migration("20240919221355_InitialMigration")] + [Migration("20240920200626_InitialMigration")] partial class InitialMigration { /// @@ -56,6 +56,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasDefaultValue(""); + b.Property("SourceControlClassId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000")); + b.Property("UpdatedUTCDate") .ValueGeneratedOnAdd() .HasColumnType("TEXT") diff --git a/database/DevHome.Database/Migrations/20240919221355_InitialMigration.cs b/database/DevHome.Database/Migrations/20240920200626_InitialMigration.cs similarity index 94% rename from database/DevHome.Database/Migrations/20240919221355_InitialMigration.cs rename to database/DevHome.Database/Migrations/20240920200626_InitialMigration.cs index 2457f9d5b0..d027b4561d 100644 --- a/database/DevHome.Database/Migrations/20240919221355_InitialMigration.cs +++ b/database/DevHome.Database/Migrations/20240920200626_InitialMigration.cs @@ -27,6 +27,7 @@ protected override void Up(MigrationBuilder migrationBuilder) IsHidden = table.Column(type: "INTEGER", nullable: false), ConfigurationFileLocation = table.Column(type: "TEXT", nullable: true, defaultValue: string.Empty), RepositoryUri = table.Column(type: "TEXT", nullable: true, defaultValue: string.Empty), + SourceControlClassId = table.Column(type: "TEXT", nullable: true, defaultValue: Guid.Empty), CreatedUTCDate = table.Column(type: "TEXT", nullable: true, defaultValueSql: "datetime()"), UpdatedUTCDate = table.Column(type: "TEXT", nullable: true, defaultValueSql: "datetime()"), }, diff --git a/database/DevHome.Database/Migrations/DevHomeDatabaseContextModelSnapshot.cs b/database/DevHome.Database/Migrations/DevHomeDatabaseContextModelSnapshot.cs index 2103343a06..be29f4a828 100644 --- a/database/DevHome.Database/Migrations/DevHomeDatabaseContextModelSnapshot.cs +++ b/database/DevHome.Database/Migrations/DevHomeDatabaseContextModelSnapshot.cs @@ -53,6 +53,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasDefaultValue(string.Empty); + b.Property("SourceControlClassId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(Guid.Empty); + b.Property("UpdatedUTCDate") .ValueGeneratedOnAdd() .HasColumnType("TEXT") diff --git a/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs b/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs index 609a4d53ff..42d7014a8f 100644 --- a/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs +++ b/database/DevHome.Database/Services/RepositoryManagementDataAccessService.cs @@ -21,7 +21,8 @@ public class RepositoryManagementDataAccessService private readonly DevHomeDatabaseContextFactory _databaseContextFactory; - public RepositoryManagementDataAccessService(DevHomeDatabaseContextFactory databaseContextFactory) + public RepositoryManagementDataAccessService( + DevHomeDatabaseContextFactory databaseContextFactory) { _databaseContextFactory = databaseContextFactory; } @@ -30,20 +31,23 @@ public RepositoryManagementDataAccessService(DevHomeDatabaseContextFactory datab /// Makes a new Repository entity with the provided name and location then saves it /// to the database. /// - /// The name of the repository to add. - /// The local location the repository is cloned to. /// The new repository. Can return null if the database threw an exception. - public Repository? MakeRepository(string repositoryName, string cloneLocation, Uri repositoryUri) + public Repository MakeRepository(string repositoryName, string cloneLocation, string repositoryUri) { return MakeRepository(repositoryName, cloneLocation, string.Empty, repositoryUri); } - public Repository? MakeRepository(string repositoryName, string cloneLocation, string configurationFileLocationAndName, Uri repositoryUri) + public Repository MakeRepository(string repositoryName, string cloneLocation, string configurationFileLocationAndName, string repositoryUri) + { + return MakeRepository(repositoryName, cloneLocation, configurationFileLocationAndName, repositoryUri, null); + } + + public Repository MakeRepository(string repositoryName, string cloneLocation, string configurationFileLocationAndName, string repositoryUri, Guid? sourceControlProviderClassId) { var existingRepository = GetRepository(repositoryName, cloneLocation); if (existingRepository != null) { - _log.Warning($"A Repository with name {repositoryName} and clone location {cloneLocation} exists in the repository already."); + _log.Information($"A Repository with name {repositoryName} and clone location {cloneLocation} exists in the repository already."); return existingRepository; } @@ -51,7 +55,8 @@ public RepositoryManagementDataAccessService(DevHomeDatabaseContextFactory datab { RepositoryName = repositoryName, RepositoryClonePath = cloneLocation, - RepositoryUri = repositoryUri.ToString(), + RepositoryUri = repositoryUri, + SourceControlClassId = sourceControlProviderClassId, }; if (!string.IsNullOrEmpty(configurationFileLocationAndName)) @@ -78,7 +83,7 @@ public RepositoryManagementDataAccessService(DevHomeDatabaseContextFactory datab TelemetryFactory.Get().Log( "DevHome_Database_Event", LogLevel.Critical, - new DatabaseEvent(nameof(MakeRepository), ex)); + new DevHomeDatabaseEvent(nameof(MakeRepository), ex)); return new Repository(); } @@ -142,8 +147,6 @@ public bool UpdateCloneLocation(Repository repository, string newLocation) return false; } - // TODO: Figure out a method to update the entity in the database and - // the entity in memory. // Maybe update the tracking information on repository. This way // EF will catch the change. repository.RepositoryClonePath = newLocation; @@ -173,6 +176,38 @@ public bool UpdateCloneLocation(Repository repository, string newLocation) return true; } + public bool SetSourceControlId(Repository repository, Guid sourceControlId) + { + try + { + using var dbContext = _databaseContextFactory.GetNewContext(); + var repositoryToUpdate = dbContext.Repositories.Find(repository.RepositoryId); + if (repositoryToUpdate == null) + { + _log.Warning($"{nameof(UpdateCloneLocation)} was called with a RepositoryId of {repository.RepositoryId} and it does not exist in the database."); + return false; + } + + // TODO: Figure out a method to update the entity in the database and + // the entity in memory. + repository.SourceControlClassId = sourceControlId; + repositoryToUpdate.SourceControlClassId = sourceControlId; + + dbContext.SaveChanges(); + } + catch (Exception ex) + { + _log.Error(ex, "Exception when updating the clone location."); + TelemetryFactory.Get().Log( + "DevHome_Database_Event", + LogLevel.Critical, + new DatabaseEvent(nameof(UpdateCloneLocation), ex)); + return false; + } + + return true; + } + public void SetIsHidden(Repository repository, bool isHidden) { try @@ -214,7 +249,6 @@ public void RemoveRepository(Repository repository) } dbContext.Repositories.Remove(repositoryToRemove); - dbContext.SaveChanges(); } catch (Exception ex) diff --git a/settings/DevHome.Settings/Strings/en-us/Resources.resw b/settings/DevHome.Settings/Strings/en-us/Resources.resw index 69d49584de..919bd011d9 100644 --- a/settings/DevHome.Settings/Strings/en-us/Resources.resw +++ b/settings/DevHome.Settings/Strings/en-us/Resources.resw @@ -583,4 +583,10 @@ Repository Management + + Toggle to allow tester to change the source control provider. + + + Show Souce Control Provider + \ No newline at end of file diff --git a/src/App.xaml.cs b/src/App.xaml.cs index 906d1ea825..3b5a56e6ec 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -100,9 +100,6 @@ public App() }). ConfigureServices((context, services) => { - // Add databse connection - services.AddDatabase(context); - // Add Serilog logging for ILogger. services.AddLogging(lb => lb.AddSerilog(dispose: true)); diff --git a/src/NavConfig.jsonc b/src/NavConfig.jsonc index 5556e2f6ec..f83e7a23b8 100644 --- a/src/NavConfig.jsonc +++ b/src/NavConfig.jsonc @@ -178,6 +178,28 @@ "openPage": { "key": "DevHome.RepositoryManagement.ViewModels.RepositoryManagementMainPageView" } + }, + { + "identity": "RepositoryManagementSourceControlSelector", + "enabledByDefault": true, + "needsFeaturePresenceCheck": false, + "buildTypeOverrides": [ + { + "buildType": "dev", + "enabledByDefault": false, + "visible": true + }, + { + "buildType": "canary", + "enabledByDefault": false, + "visible": false + }, + { + "buildType": "stable", + "enabledByDefault": false, + "visible": false + } + ] } - ] + ] } diff --git a/src/Services/PageService.cs b/src/Services/PageService.cs index fecad33543..834d413e8a 100644 --- a/src/Services/PageService.cs +++ b/src/Services/PageService.cs @@ -57,8 +57,8 @@ where assembly.GetName().Name == tool.Assembly { var enabledByDefault = experimentalFeature.EnabledByDefault; var needsFeaturePresenceCheck = experimentalFeature.NeedsFeaturePresenceCheck; - var openPageKey = experimentalFeature.OpenPage.Key; - var openPageParameter = experimentalFeature.OpenPage.Parameter; + var openPageKey = experimentalFeature.OpenPage?.Key ?? string.Empty; + var openPageParameter = experimentalFeature.OpenPage?.Parameter ?? string.Empty; var isVisible = true; foreach (var buildTypeOverride in experimentalFeature.BuildTypeOverrides ?? Array.Empty()) { diff --git a/test/Database/RepositoryTests.cs b/test/Database/RepositoryTests.cs index 73aa32302e..d5764ac8db 100644 --- a/test/Database/RepositoryTests.cs +++ b/test/Database/RepositoryTests.cs @@ -11,14 +11,16 @@ namespace DevHome.Test.Database; [TestClass] public class RepositoryTests { - private const string ConfigurationFileLocation = @"The\Best\Configuration\Location"; - - private const string CloneLocation = @"The\Best\File\Location"; + private const string CloneLocation = @"TestSource\repos"; private const string RepositoryName = "DevHome"; + private const string ConfigurationFileLocation = @".configurations\configuration.dsc.yaml"; + private const string RepositoryUri = "https://www.github.com/microsoft/devhome"; + private readonly string _repositoryCloneLocation = Path.Join(CloneLocation, RepositoryName); + [TestInitialize] public void ResetDatabase() { @@ -33,6 +35,26 @@ public void ResetDatabase() dbContext.Database.EnsureDeleted(); dbContext.Database.EnsureCreated(); + + if (Directory.Exists(_repositoryCloneLocation)) + { + // Cumbersome, but needed to remove read-only files. + foreach (var repositoryFile in Directory.EnumerateFiles(_repositoryCloneLocation, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(repositoryFile, FileAttributes.Normal); + File.Delete(repositoryFile); + } + + foreach (var repositoryDirectory in Directory.GetDirectories(_repositoryCloneLocation, "*", SearchOption.AllDirectories).Reverse()) + { + Directory.Delete(repositoryDirectory); + } + + File.SetAttributes(_repositoryCloneLocation, FileAttributes.Normal); + Directory.Delete(_repositoryCloneLocation, false); + } + + LibGit2Sharp.Repository.Clone(RepositoryUri, _repositoryCloneLocation); } [TestMethod] @@ -86,5 +108,6 @@ public void MakefilledInRepository() Assert.IsTrue(savedRepository.IsHidden); Assert.IsTrue(savedRepository.HasAConfigurationFile); Assert.AreEqual(RepositoryUri, savedRepository.RepositoryUri); + Assert.IsTrue(savedRepository.HasAConfigurationFile); } } diff --git a/tools/Customization/DevHome.Customization/ViewModels/FileExplorerViewModel.cs b/tools/Customization/DevHome.Customization/ViewModels/FileExplorerViewModel.cs index 2a4845ed55..69c08da471 100644 --- a/tools/Customization/DevHome.Customization/ViewModels/FileExplorerViewModel.cs +++ b/tools/Customization/DevHome.Customization/ViewModels/FileExplorerViewModel.cs @@ -156,14 +156,14 @@ public bool ShowRepositoryStatus } [RelayCommand] - public async Task AddFolderClick() + public async Task AddFolderClick() { + StorageFolder? repoRootFolder = null; if (IsFeatureEnabled) { await Task.Run(async () => { using var folderDialog = new WindowOpenFolderDialog(); - StorageFolder? repoRootFolder = null; try { @@ -186,6 +186,14 @@ await Task.Run(async () => }); RefreshTrackedRepositories(); } + + return repoRootFolder == null ? string.Empty : repoRootFolder.Path; + } + + public void AddRepositoryAlreadyOnMachine(string path) + { + RepoTracker.AddRepositoryPath(_unassigned, path); + RefreshTrackedRepositories(); } public void RemoveTrackedRepositoryFromDevHome(string rootPath) diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/DevHome.RepositoryManagement.csproj b/tools/RepositoryManagement/DevHome.RepositoryManagement/DevHome.RepositoryManagement.csproj index ed8f2a7b34..553c64e602 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/DevHome.RepositoryManagement.csproj +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/DevHome.RepositoryManagement.csproj @@ -12,6 +12,7 @@ + all @@ -22,7 +23,8 @@ - + + @@ -30,8 +32,4 @@ MSBuild:Compile - - - - \ No newline at end of file diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Extensions/ServiceExtensions.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Extensions/ServiceExtensions.cs index 4cd055611c..c13e9c073a 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Extensions/ServiceExtensions.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Extensions/ServiceExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using DevHome.RepositoryManagement.Factories; +using DevHome.RepositoryManagement.Services; using DevHome.RepositoryManagement.ViewModels; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -14,6 +15,7 @@ public static IServiceCollection AddRepositoryManagement(this IServiceCollection { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs index 90e3de376f..ba52021a34 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Factories/RepositoryManagementItemViewModelFactory.cs @@ -3,6 +3,7 @@ using DevHome.Common.Services; using DevHome.Database.Services; +using DevHome.RepositoryManagement.Services; using DevHome.RepositoryManagement.ViewModels; using DevHome.SetupFlow.Services; using Microsoft.UI.Xaml; @@ -18,20 +19,24 @@ public class RepositoryManagementItemViewModelFactory private readonly RepositoryManagementDataAccessService _dataAccessService; - private readonly IStringResource _stringResource; - private readonly ConfigurationFileBuilder _configurationFileBuilder; + private readonly IExtensionService _extensionService; + + private readonly RepositoryEnhancerService _repositoryEnhancerService; + public RepositoryManagementItemViewModelFactory( Window window, RepositoryManagementDataAccessService dataAccess, - IStringResource stringResource, - ConfigurationFileBuilder configurationFileBuilder) + ConfigurationFileBuilder configurationFileBuilder, + IExtensionService extensionService, + RepositoryEnhancerService repositoryEnhancerService) { _window = window; _dataAccessService = dataAccess; - _stringResource = stringResource; _configurationFileBuilder = configurationFileBuilder; + _extensionService = extensionService; + _repositoryEnhancerService = repositoryEnhancerService; } public RepositoryManagementItemViewModel MakeViewModel(string repositoryName, string cloneLocation, bool isHidden) @@ -53,7 +58,14 @@ public RepositoryManagementItemViewModel MakeViewModel(string repositoryName, st localIsHidden = true; } - var newViewModel = new RepositoryManagementItemViewModel(_window, _dataAccessService, _stringResource, _configurationFileBuilder, localRepositoryName, localCloneLocation); + var newViewModel = new RepositoryManagementItemViewModel( + _window, + _dataAccessService, + _configurationFileBuilder, + _extensionService, + _repositoryEnhancerService, + localRepositoryName, + localCloneLocation); newViewModel.IsHiddenFromPage = localIsHidden; diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Models/Commit.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Models/Commit.cs new file mode 100644 index 0000000000..9fc51ac993 --- /dev/null +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Models/Commit.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace DevHome.RepositoryManagement.Models; + +public class Commit +{ + public static readonly Commit DefaultCommit = new(string.Empty, DateTime.MinValue, string.Empty); + + public string Author { get; } + + public DateTime CommitDateTime { get; } + + public string SHA { get; } + + public Commit(string author, DateTime commitDateTime, string sHA) + { + Author = author; + CommitDateTime = commitDateTime; + SHA = sHA; + } +} diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs new file mode 100644 index 0000000000..4661f88fbe --- /dev/null +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Services/RepositoryEnhancerService.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DevHome.Common.Services; +using DevHome.Customization.Helpers; +using DevHome.Customization.ViewModels; +using DevHome.Telemetry; +using FileExplorerSourceControlIntegration; +using Microsoft.UI.Xaml; +using Microsoft.Windows.DevHome.SDK; +using Serilog; +using Windows.Foundation.Collections; + +namespace DevHome.RepositoryManagement.Services; + +/// +/// Service for associating a local repository path with a source control extension. +/// +public class RepositoryEnhancerService +{ + private const string ErrorEventName = "DevHome_EnhanceRepositoryError_Event"; + + private readonly ILogger _log = Log.ForContext("SourceContext", nameof(RepositoryEnhancerService)); + + private readonly FileExplorerViewModel _sourceControlRegistrar; + + private readonly Window _window; + + private readonly ITelemetry _telemetry; + + private readonly IExtensionService _extensionService; + + public RepositoryEnhancerService( + FileExplorerViewModel sourceControlRegistrar, + Window window, + IExtensionService extensionService) + { + _sourceControlRegistrar = sourceControlRegistrar; + _telemetry = TelemetryFactory.Get(); + _window = window; + _extensionService = extensionService; + } + + public List GetAllSourceControlProviders() + { + return _extensionService.GetInstalledExtensionsAsync(ProviderType.LocalRepository).Result.ToList(); + } + + /// + /// Associates a source control provider with a local repository. + /// + /// The full path to the repositories root. + /// True if the association is made. False otherwise + public async Task MakeRepositoryEnhanced(string repositoryLocation, IExtensionWrapper sourceControlId) + { + _sourceControlRegistrar.AddRepositoryAlreadyOnMachine(repositoryLocation); + return await AssignSourceControlToPath(repositoryLocation, sourceControlId); + } + + public string GetLocalBranchName(string repositoryLocation) + { + try + { + using var repository = new LibGit2Sharp.Repository(repositoryLocation); + return repository.Head.FriendlyName; + } + catch (Exception ex) + { + _log.Error(ex, $"Error when getting branch name"); + } + + return string.Empty; + } + + public string GetRepositoryUrl(string repositoryLocation) + { + try + { + using var repository = new LibGit2Sharp.Repository(repositoryLocation); + return repository.Network.Remotes.First().Url; + } + catch (Exception ex) + { + _log.Error(ex, $"Error when getting the repositoryUrl"); + } + + return string.Empty; + } + + public IPropertySet GetProperties(string[] propertiesToReturn, string repositoryLocation) + { + var sourceControlProvider = new SourceControlProvider(); + var provider = sourceControlProvider.GetProvider(repositoryLocation); + + if (provider == null) + { + _log.Warning($"Path: {repositoryLocation} does not have an associated provider."); + return new PropertySet(); + } + + // This call does check the settings for file explorer and souce control integration. + return provider.GetProperties(propertiesToReturn, string.Empty); + } + + public async Task ReAssignSourceControl(string repositoryPath, IExtensionWrapper extensionWrapper) + { + return await _sourceControlRegistrar.AssignSourceControlProviderToRepository(extensionWrapper, repositoryPath); + } + + private async Task AssignSourceControlToPath(string maybeRepositoryPath, IExtensionWrapper extension) + { + Directory.CreateDirectory(maybeRepositoryPath); + + var assignSourceControlResult = await _sourceControlRegistrar.AssignSourceControlProviderToRepository(extension, maybeRepositoryPath); + if (assignSourceControlResult.Result == Customization.Helpers.ResultType.Success) + { + _log.Information($"Source control {extension.ExtensionDisplayName} is assigned to repository {maybeRepositoryPath}"); + return true; + } + + return false; + } +} diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw b/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw index 9f091b226d..6d56e7d829 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Strings/en-us/Resources.resw @@ -121,4 +121,144 @@ Repositories Name of the tool to help manage repositories on the user's machine. + + {0} file + File filter example for chossing where to save a configuration file. + + + Would you like to delete this repository? + Message asking the user to confirm deleting the repository. + + + Deleting a repository means it will be permanently removed in File Explorer and from your PC. + Message explaining what will happen if the repository is deleted. + + + Yes + Confirmation word + + + Cancel + Cancel + + + Cannot find {0} + {Locked="{0}"} {0} name of the repository. Title for not able to find a repository. + + + Cannot find {0} at {1}. Can you provide the path to this repository? + {Locked="{0},{1}"} {0} Repository name. {1} Local clone path. Message asking if the user knows where this repository is located. + + + Select repository root directory + Option to find the repository via the File Explorer. + + + Remove from list + Permission to remove the repository from the list. + + + Add repository + Button content for adding an existing repository + + + Repositories + Title of the Repository Management page for the header + + + Clone repository + Button allowing used to navigate to the repository flow + + + Filter + Place holder text for the text box. + + + Sort: + Introduces the sorting combo box. + + + A to Z + Repositories are sorted from the beginning of the alphabet to the end. + + + Z to A + Repositories are sorted from the end of the alphabet to the beginning. + + + Name + Repository name header + + + Clone path + Repository clone path header + + + Latest commit + Latest commit header + + + Branch + Branch header + + + WinGet configuration file found. Run it from the '...' menu to set up this repo + Tooltip telling users to use "..." to run the configuration file. + + + Open in File Explorer + Option to open the selected repository in file explorer. + + + Open in Command Line + Option to open the selected repository in cmd + + + Move Repository + Option to move the selected repository to a new location + + + Add to WinGet configuration file + Option to make a new configuration file with this repository + + + Run WinGet configuration file + Option to run the detected configuration file in an admin command prompt + + + Remove from this list + Option to remove the selected repository from the list + + + Delete + Option to remove the repository from the computer + + + Not found + message in the repository management when commit information isn't found. + + + More options for repository {0} + {Locked="{0}"} {0} name of the repository. Automation name for the ... menu + + + min + The shorthand name for minute. + + + Provided by {0} + Prefix for Dev Home version that appears on the ToolTip for source control extension drop down + + + unassigned + Label when a repository does not have an assigned source control provider. + + + Close + Button text for the close button. + + + Error assigning source control provider + Title of the source control error dialog. + \ No newline at end of file diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs index 7f3f4350c3..7c2590c75a 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementItemViewModel.cs @@ -11,20 +11,24 @@ using DevHome.Common.Services; using DevHome.Common.TelemetryEvents.RepositoryManagement; using DevHome.Common.Windows.FileDialog; +using DevHome.Customization.Helpers; using DevHome.Database.DatabaseModels.RepositoryManagement; using DevHome.Database.Services; +using DevHome.RepositoryManagement.Services; using DevHome.SetupFlow.Services; using DevHome.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.Windows.DevHome.SDK; using Serilog; namespace DevHome.RepositoryManagement.ViewModels; -// TODO: Clean up the code. public partial class RepositoryManagementItemViewModel : ObservableObject { - public const string EventName = "DevHome_RepositoryLineItem_Event"; + public const string EventName = "DevHome_RepositorySpecific_Event"; + + public const string ErrorEventName = "DevHome_RepositorySpecificError_Event"; private readonly ILogger _log = Log.ForContext("SourceContext", nameof(RepositoryManagementItemViewModel)); @@ -32,10 +36,14 @@ public partial class RepositoryManagementItemViewModel : ObservableObject private readonly RepositoryManagementDataAccessService _dataAccess; - private readonly IStringResource _stringResource; + private readonly StringResource _stringResource = new("DevHome.RepositoryManagement.pri", "DevHome.RepositoryManagement/Resources"); private readonly ConfigurationFileBuilder _configurationFileBuilder; + private readonly RepositoryEnhancerService _repositoryEnhancerService; + + private readonly IExtensionService _extensionService; + /// /// Gets the name of the repository. /// @@ -74,6 +82,65 @@ public string Branch public bool HasAConfigurationFile { get; set; } + public string LatestCommitSHA { get; set; } + + public string LatestCommitAuthor { get; set; } + + public string MinutesSinceLatestCommit { get; set; } + + public bool HasCommitInformation { get; set; } + + public string MoreOptionsButtonAutomationName { get; set; } + + [ObservableProperty] + private string _sourceControlProviderDisplayName; + + [ObservableProperty] + private string _sourceControlProviderPackageDisplayName; + + public string SourceControlExtensionClassId { get; set; } + + [ObservableProperty] + private MenuFlyout _allSourceControlProviderNames; + + [RelayCommand] + public void UpdateSourceControlProviderNames() + { + AllSourceControlProviderNames.Items.Clear(); + foreach (var extension in _extensionService.GetInstalledExtensionsAsync(ProviderType.LocalRepository).Result.ToList()) + { + var menuItem = new MenuFlyoutItem + { + Text = extension.ExtensionDisplayName, + Tag = extension, + }; + + menuItem.Command = AssignRepositoryANewSourceControlProviderCommand; + menuItem.CommandParameter = extension; + + ToolTipService.SetToolTip(menuItem, _stringResource.GetLocalized("PrefixForDevHomeVersion", extension.PackageDisplayName)); + AllSourceControlProviderNames.Items.Add(menuItem); + } + } + + [RelayCommand] + public async Task AssignRepositoryANewSourceControlProvider(IExtensionWrapper extensionWrapper) + { + if (!string.Equals(extensionWrapper.ExtensionClassId, SourceControlExtensionClassId, StringComparison.OrdinalIgnoreCase)) + { + var result = await _repositoryEnhancerService.ReAssignSourceControl(ClonePath, extensionWrapper); + if (result.Result != ResultType.Success) + { + ShowErrorContentDialog(result); + } + else + { + var repository = GetRepositoryReportIfNull(nameof(AssignRepositoryANewSourceControlProvider)); + _dataAccess.SetSourceControlId(repository, Guid.Parse(extensionWrapper.ExtensionClassId)); + } + } + } + [RelayCommand] public async Task OpenInFileExplorer() { @@ -91,7 +158,9 @@ public async Task OpenInCMD() [RelayCommand] public async Task MoveRepository() { - // TODO: Save to the database before moving the folder. + // This action is not enabled due to a bug in FileExploreGitIntegration. + // FileExplorerGitIntegration holds a lock on a file in this repository and it can not + // be moved. var newLocation = await PickNewLocationForRepositoryAsync(); if (string.IsNullOrEmpty(newLocation)) @@ -141,8 +210,11 @@ public async Task MoveRepository() [RelayCommand] public async Task DeleteRepositoryAsync() { - // TODO: Add repository name and the location to the dialog. - // Ask user to type in the repository name before removing. + // TODO: Add repository name and the location to the dialog. + // TODO: Ask user to type in the repository name before removing. + // This action is not enabled due to a bug in FileExploreGitIntegration. + // FileExplorerGitIntegration holds a lock on a file in this repository and it can not + // be moved. var cantFindRepositoryDialog = new ContentDialog() { XamlRoot = _window.Content.XamlRoot, @@ -224,10 +296,8 @@ public async Task MakeConfigurationFileWithThisRepository() { // Show the save file dialog using var fileDialog = new WindowSaveFileDialog(); - - // TODO: Needs Localization - fileDialog.AddFileType(_stringResource.GetLocalized("{0} file", "YAML"), ".winget"); - fileDialog.AddFileType(_stringResource.GetLocalized("{0} file", "YAML"), ".dsc.yaml"); + fileDialog.AddFileType(_stringResource.GetLocalized("ConfigurationFileNameFormat", "YAML"), ".winget"); + fileDialog.AddFileType(_stringResource.GetLocalized("ConfigurationFileNameFormat", "YAML"), ".dsc.yaml"); var fileName = fileDialog.Show(_window); // If the user selected a file, write the configuration to it @@ -278,17 +348,20 @@ public void RunConfigurationFile() internal RepositoryManagementItemViewModel( Window window, RepositoryManagementDataAccessService dataAccess, - IStringResource stringResource, ConfigurationFileBuilder configurationFileBuilder, + IExtensionService extensionService, + RepositoryEnhancerService repositoryEnhancerService, string repositoryName, string cloneLocation) { _window = window; _dataAccess = dataAccess; - _stringResource = stringResource; _configurationFileBuilder = configurationFileBuilder; RepositoryName = repositoryName; _clonePath = cloneLocation; + _extensionService = extensionService; + _allSourceControlProviderNames = new MenuFlyout(); + _repositoryEnhancerService = repositoryEnhancerService; } public void RemoveThisRepositoryFromTheList() @@ -401,13 +474,17 @@ private async Task ShowCloneLocationNotFoundDialogAsync() var cantFindRepositoryDialog = new ContentDialog() { XamlRoot = _window.Content.XamlRoot, - Title = $"Can not find {RepositoryName}.", - Content = $"Cannot find {RepositoryName} at {Path.GetFullPath(ClonePath)}. Do you know where it is?", - PrimaryButtonText = $"Locate {RepositoryName} via File Explorer.", - SecondaryButtonText = "Remove from list", - CloseButtonText = "Cancel", + Title = _stringResource.GetLocalized("LocateRepositoryDialogTitle", RepositoryName), + Content = _stringResource.GetLocalized("LocateRepositoryDialogContent", RepositoryName, ClonePath), + PrimaryButtonText = _stringResource.GetLocalized("LocateRepositoryDialogFindWithFileExplorer"), + SecondaryButtonText = _stringResource.GetLocalized("LocateRepositoryRemoveFromListInstead"), + CloseButtonText = _stringResource.GetLocalized("Cancel"), }; + // https://github.com/microsoft/microsoft-ui-xaml/issues/424 + // Setting MaxWidth does not change the dialog size. + cantFindRepositoryDialog.Resources["ContentDialogMaxWidth"] = 700; + ContentDialogResult dialogResult = ContentDialogResult.None; try @@ -433,6 +510,7 @@ private async Task ShowCloneLocationNotFoundDialogAsync() } var repository = GetRepositoryReportIfNull(nameof(ShowCloneLocationNotFoundDialogAsync)); + if (repository == null) { return; @@ -483,4 +561,16 @@ private async Task CheckCloneLocationNotifyUserIfNotFound() await ShowCloneLocationNotFoundDialogAsync(); } } + + public async void ShowErrorContentDialog(SourceControlValidationResult result) + { + var errorDialog = new ContentDialog + { + Title = _stringResource.GetLocalized("ErrorAssigningSourceControlProvider"), + Content = result.DisplayMessage, + CloseButtonText = _stringResource.GetLocalized("CloseButtonText"), + XamlRoot = _window.Content.XamlRoot, + }; + _ = await errorDialog.ShowAsync(); + } } diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs index 0937f9c86d..947dd12fb5 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/ViewModels/RepositoryManagementMainPageViewModel.cs @@ -4,61 +4,174 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.Eventing.Reader; +using System.IO; using System.Linq; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DevHome.Common.Services; using DevHome.Common.Windows.FileDialog; using DevHome.Database.DatabaseModels.RepositoryManagement; using DevHome.Database.Services; using DevHome.RepositoryManagement.Factories; +using DevHome.RepositoryManagement.Models; +using DevHome.RepositoryManagement.Services; +using DevHome.SetupFlow.Common.Helpers; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Serilog; +using Windows.Foundation.Collections; +using Windows.Storage; namespace DevHome.RepositoryManagement.ViewModels; -public partial class RepositoryManagementMainPageViewModel +public partial class RepositoryManagementMainPageViewModel : ObservableObject { private readonly ILogger _log = Log.ForContext("SourceContext", nameof(RepositoryManagementMainPageViewModel)); + private static readonly string[] _commitProperties = + [ + "System.VersionControl.LastChangeAuthorName", + "System.VersionControl.LastChangeDate", + "System.VersionControl.LastChangeID", + ]; + private readonly INavigationService _navigationService; private readonly RepositoryManagementItemViewModelFactory _factory; private readonly RepositoryManagementDataAccessService _dataAccessService; + private readonly StringResource _stringResource = new("DevHome.RepositoryManagement.pri", "DevHome.RepositoryManagement/Resources"); + + private readonly RepositoryEnhancerService _enhanceRepositoryService; + private readonly Window _window; - // _items and Items are renamed in user/dhoehna/TODOsAndUsingTheGitExtension - // to descriptive names. - private readonly List _items = []; + private readonly IExperimentationService _experimentationService; + + private List _allLineItems = []; + + private List _allRepositoriesFromTheDatabase; + + [ObservableProperty] + private ObservableCollection _lineItemsToDisplay; + + [ObservableProperty] + private string _filterText = string.Empty; + + [ObservableProperty] + private bool _areFilterAndSortEnabled; + + [ObservableProperty] + private bool _shouldShowSourceControlSelection; + + public enum SortOrder + { + NameAscending, + NameDescending, + } + + private SortOrder _sortTag = SortOrder.NameAscending; + + [RelayCommand] + public void ChangeSortOrder(ComboBoxItem selectedItem) + { + if (selectedItem == null) + { + _log.Warning($"selectedItem in {nameof(ChangeSortOrder)} is null. Not changing order."); + return; + } + + if (selectedItem.Tag == null) + { + _log.Warning($"selectedItem.Tag in {nameof(ChangeSortOrder)} is null. Not changing order."); + return; + } + + var sortOrder = selectedItem.Tag.ToString(); + if (sortOrder.Equals(SortOrder.NameAscending.ToString(), StringComparison.OrdinalIgnoreCase)) + { + _sortTag = SortOrder.NameAscending; + } + else + { + _sortTag = SortOrder.NameDescending; + } + + AreFilterAndSortEnabled = false; - public ObservableCollection Items { get; private set; } + UpdateDisplayedRepositories(); + + AreFilterAndSortEnabled = true; + } + + [RelayCommand] + public void FilterRepositories() + { + UpdateDisplayedRepositories(); + } [RelayCommand] public async Task AddExistingRepository() { - try + AreFilterAndSortEnabled = false; + + var existingRepositoryLocation = await GetRepositoryLocationFromUser(); + if (existingRepositoryLocation.Equals(string.Empty, StringComparison.OrdinalIgnoreCase)) { - // TODO: Use extensions to determine if the selected location is a repository. - // Adding the repository to the database is implemented in - // user/dhoehna/TODOsAndUsingTheGitExtension - _log.Information("Opening folder picker to select a new location"); - using var folderPicker = new WindowOpenFolderDialog(); - var newLocation = await folderPicker.ShowAsync(_window); - if (newLocation != null && newLocation.Path.Length > 0) - { - _log.Information($"Selected '{newLocation.Path}' for the repository path."); - } - else + _log.Warning($"Repository in {nameof(AddExistingRepository)} is either empty."); + return; + } + + var foundProvider = false; + var sourceControlProviderGuid = Guid.Empty; + foreach (var sourceControlProvider in _enhanceRepositoryService.GetAllSourceControlProviders()) + { + foundProvider = await _enhanceRepositoryService.MakeRepositoryEnhanced(existingRepositoryLocation, sourceControlProvider); + if (foundProvider) { - _log.Information("Didn't select a location to clone to"); + // sourceControlProviderGuid already set to Guid.Empty in case this fails. + if (Guid.TryParse(sourceControlProvider.ExtensionClassId, out sourceControlProviderGuid)) + { + break; + } + else + { + _log.Warning($"The valid source control provider id {sourceControlProvider.ExtensionClassId} could not be parsed into a string."); + foundProvider = false; + } } } - catch (Exception ex) + + var repositoryUrl = _enhanceRepositoryService.GetRepositoryUrl(existingRepositoryLocation); + var configurationFileLocation = DscHelpers.GetConfigurationFileIfExists(existingRepositoryLocation); + var newRepository = _dataAccessService.MakeRepository( + Path.GetFileName(existingRepositoryLocation), + existingRepositoryLocation, + configurationFileLocation, + repositoryUrl, + sourceControlProviderGuid); + + _allRepositoriesFromTheDatabase.Add(newRepository); + + var repositoryWithCommit = GetRepositoryAndLatestCommitPairs(new List { newRepository }); + var newLineItem = ConvertToLineItems(repositoryWithCommit); + + if (newLineItem.Count > 0) + { + _allLineItems.Add(newLineItem[0]); + } + else { - _log.Error(ex, "Failed to open folder picker"); + _log.Warning("A new line item was not made."); } + + UpdateDisplayedRepositories(); + + AreFilterAndSortEnabled = true; } [RelayCommand] @@ -68,13 +181,24 @@ public void NavigateToCloneRepositoryExpirence() } [RelayCommand] - public void LoadRepositories() + public async Task LoadRepositories() { - _items.Clear(); - Items.Clear(); - var repositoriesFromTheDatabase = _dataAccessService.GetRepositories(); - ConvertToLineItems(repositoriesFromTheDatabase).ForEach(x => _items.Add(x)); - _items.Where(x => x.IsHiddenFromPage == false).ToList().ForEach(x => Items.Add(x)); + // TODO: Spinning progress ring when loading repositories. + AreFilterAndSortEnabled = false; + _allRepositoriesFromTheDatabase = _dataAccessService.GetRepositories(); + _allRepositoriesFromTheDatabase = await AssignSourceControlId(_allRepositoriesFromTheDatabase); + + var repositoriesWithCommits = GetRepositoryAndLatestCommitPairs(_allRepositoriesFromTheDatabase); + + _allLineItems.Clear(); + LineItemsToDisplay.Clear(); + + _allLineItems = ConvertToLineItems(repositoriesWithCommits); + LineItemsToDisplay = new(HideFilterAndSort(_allLineItems).Where(x => x.IsHiddenFromPage == false).ToList()); + + ShouldShowSourceControlSelection = _experimentationService.IsFeatureEnabled("RepositoryManagementSourceControlSelector"); + + AreFilterAndSortEnabled = true; } [RelayCommand] @@ -85,38 +209,230 @@ public void HideRepository(RepositoryManagementItemViewModel repository) return; } + AreFilterAndSortEnabled = false; + repository.RemoveThisRepositoryFromTheList(); - LoadRepositories(); + repository.IsHiddenFromPage = true; + UpdateDisplayedRepositories(); + + AreFilterAndSortEnabled = true; } public RepositoryManagementMainPageViewModel( RepositoryManagementItemViewModelFactory factory, RepositoryManagementDataAccessService dataAccessService, INavigationService navigationService, - Window window) + RepositoryEnhancerService enchanceRepositoryService, + Window window, + IExperimentationService experimentationService) { _dataAccessService = dataAccessService; _factory = factory; - Items = []; + LineItemsToDisplay = []; _navigationService = navigationService; + _enhanceRepositoryService = enchanceRepositoryService; _window = window; + _experimentationService = experimentationService; + } + + private void UpdateDisplayedRepositories() + { + LineItemsToDisplay.Clear(); + var lineItemsToShow = HideFilterAndSort(_allLineItems); + lineItemsToShow.ForEach(x => LineItemsToDisplay.Add(x)); } - private List ConvertToLineItems(List repositories) + private List ConvertToLineItems(List<(Repository, Commit)> repositories) { + var sourceControlProviders = _enhanceRepositoryService.GetAllSourceControlProviders(); _log.Information("Converting repositories from the database into view models for display"); List items = []; - foreach (var repo in repositories) + foreach (var repositoryWithCommit in repositories) { - // TODO: get correct values for branch and latest commit information - var lineItem = _factory.MakeViewModel(repo.RepositoryName, repo.RepositoryClonePath, repo.IsHidden); - lineItem.Branch = "Test Value"; - lineItem.LatestCommit = "Test Value"; - lineItem.HasAConfigurationFile = repo.HasAConfigurationFile; + var repository = repositoryWithCommit.Item1; + var lineItem = _factory.MakeViewModel(repository.RepositoryName, repository.RepositoryClonePath, repository.IsHidden); + lineItem.Branch = _enhanceRepositoryService.GetLocalBranchName(repository.RepositoryClonePath); + + var commit = repositoryWithCommit.Item2; + + if (commit == Commit.DefaultCommit) + { + lineItem.HasCommitInformation = false; + } + else + { + lineItem.HasCommitInformation = true; + lineItem.LatestCommitAuthor = commit.Author; + lineItem.LatestCommitSHA = commit.SHA; + + var commitInMinutes = Convert.ToInt32((DateTime.Now - commit.CommitDateTime).TotalMinutes); + var minutString = _stringResource.GetLocalized("MinuteAbbreviation"); + lineItem.MinutesSinceLatestCommit = $"{commitInMinutes} {minutString}"; + } + + var sourceControlProvider = sourceControlProviders.SingleOrDefault(x => Guid.Parse(x.ExtensionClassId) == repository.SourceControlClassId); + if (sourceControlProvider != null) + { + lineItem.SourceControlExtensionClassId = sourceControlProvider.ExtensionClassId; + lineItem.SourceControlProviderDisplayName = sourceControlProvider.ExtensionDisplayName; + lineItem.SourceControlProviderPackageDisplayName = sourceControlProvider.PackageFullName; + } + else + { + lineItem.SourceControlExtensionClassId = Guid.Empty.ToString(); + lineItem.SourceControlProviderDisplayName = _stringResource.GetLocalized("UnassignedSourceControlProvider"); + lineItem.SourceControlProviderPackageDisplayName = _stringResource.GetLocalized("UnassignedSourceControlProvider"); + } + + lineItem.HasAConfigurationFile = repository.HasAConfigurationFile; + lineItem.MoreOptionsButtonAutomationName = _stringResource.GetLocalized("MoreOptionsAutomationName", repository.RepositoryName); + items.Add(lineItem); } return items; } + + private async Task> AssignSourceControlId(List repositories) + { + var sourceControlProviders = _enhanceRepositoryService.GetAllSourceControlProviders(); + + foreach (var repository in repositories) + { + if (!repository.HasAssignedSourceControlProvider) + { + var foundExtension = false; + foreach (var extension in sourceControlProviders) + { + foundExtension = await _enhanceRepositoryService.MakeRepositoryEnhanced(repository.RepositoryClonePath, extension); + var sourceControlExtensionId = Guid.Empty; + if (foundExtension && Guid.TryParse(extension.ExtensionClassId, out sourceControlExtensionId)) + { + _dataAccessService.SetSourceControlId(repository, sourceControlExtensionId); + } + else + { + _log.Warning($"Could not assign source control {extension.ExtensionDisplayName} to the repository"); + } + } + } + } + + return repositories; + } + + private List HideFilterAndSort(List repositories) + { + IEnumerable filteredAndSortedRepositories = repositories.Where(x => !x.IsHiddenFromPage); + + if (!string.IsNullOrEmpty(FilterText)) + { + filteredAndSortedRepositories = filteredAndSortedRepositories.Where(x => x.RepositoryName.Contains(FilterText, StringComparison.OrdinalIgnoreCase)); + } + + if (_sortTag == SortOrder.NameAscending) + { + filteredAndSortedRepositories = filteredAndSortedRepositories.OrderBy(x => x.RepositoryName); + } + else + { + filteredAndSortedRepositories = filteredAndSortedRepositories.OrderByDescending(x => x.RepositoryName); + } + + return filteredAndSortedRepositories.ToList(); + } + + private List<(Repository, Commit)> GetRepositoryAndLatestCommitPairs(List repositories) + { + var repositoriesButWithCommits = new List<(Repository, Commit)>(); + + foreach (var repository in repositories) + { + var sourceControlId = repository.HasAssignedSourceControlProvider ? repository.SourceControlClassId ?? Guid.Empty : Guid.Empty; + var latestCommit = GetLatestCommitInformation(repository.RepositoryClonePath, sourceControlId); + + repositoriesButWithCommits.Add((repository, latestCommit)); + } + + return repositoriesButWithCommits; + } + + private Commit GetLatestCommitInformation(string repositoryLocation, Guid sourceControlProviderClassId) + { + if (sourceControlProviderClassId == Guid.Empty) + { + _log.Warning($"sourceControlProviderClassId is guid.empty. {sourceControlProviderClassId}. Can not get commit information"); + return Commit.DefaultCommit; + } + + IPropertySet repositoryProperties = new PropertySet(); + + try + { + repositoryProperties = _enhanceRepositoryService.GetProperties(_commitProperties, repositoryLocation); + } + catch (Exception ex) + { + _log.Error(ex, $"Error with getting properties from the repository"); + } + + if (!repositoryProperties.TryGetValue("System.VersionControl.LastChangeAuthorName", out var latestCommitAuthorName)) + { + return Commit.DefaultCommit; + } + + if (!repositoryProperties.TryGetValue("System.VersionControl.LastChangeDate", out var latestCommitChangedDate)) + { + return Commit.DefaultCommit; + } + + if (!repositoryProperties.TryGetValue("System.VersionControl.LastChangeID", out var latestCommitSHA)) + { + return Commit.DefaultCommit; + } + else + { + if (latestCommitSHA.ToString().Length > 6) + { + latestCommitSHA = latestCommitSHA.ToString().Substring(0, 6); + } + } + + DateTime latestCommitDateTime; + + // latestCommitDateTime can be in the future causing diff(now - latestCommitDateTime) + // to be negative. Show the negative value. Be transparent. + // The future date might have been on purpose. + if (!DateTime.TryParse(latestCommitChangedDate.ToString(), out latestCommitDateTime)) + { + latestCommitAuthorName = DateTime.MinValue; + } + + return new(latestCommitAuthorName.ToString(), latestCommitDateTime, latestCommitSHA.ToString()); + } + + private async Task GetRepositoryLocationFromUser() + { + StorageFolder repositoryRootFolder = null; + try + { + using var folderDialog = new WindowOpenFolderDialog(); + repositoryRootFolder = await folderDialog.ShowAsync(_window); + } + catch (Exception ex) + { + _log.Error(ex, $"Error occurred when selecting a folder for adding a repository."); + return string.Empty; + } + + if (repositoryRootFolder == null || string.IsNullOrEmpty(repositoryRootFolder.Path)) + { + _log.Information("User did not select a location to register"); + return string.Empty; + } + + _log.Information($"User selected '{repositoryRootFolder.Path}' as location to register"); + return repositoryRootFolder.Path; + } } diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml index 6c1218d247..059b5ef8f5 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml @@ -13,6 +13,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewmodels="using:DevHome.RepositoryManagement.ViewModels" behaviors:NavigationViewHeaderBehavior.HeaderMode="Never" + xmlns:converters="using:CommunityToolkit.WinUI.Converters" x:Name="RepositoryManagementMainPage" mc:Ignorable="d"> @@ -33,6 +34,56 @@ + + + + + + + + + + + + + + + + + + + + @@ -55,28 +106,25 @@ - - - - - diff --git a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs index c1f20c5848..f27d0fbd8d 100644 --- a/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs +++ b/tools/RepositoryManagement/DevHome.RepositoryManagement/Views/RepositoryManagementMainPageView.xaml.cs @@ -5,6 +5,8 @@ using DevHome.Common.Views; using DevHome.RepositoryManagement.ViewModels; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Windows.DevHome.SDK; namespace DevHome.RepositoryManagement.Views; diff --git a/tools/SetupFlow/DevHome.SetupFlow.Common/DevHome.SetupFlow.Common.csproj b/tools/SetupFlow/DevHome.SetupFlow.Common/DevHome.SetupFlow.Common.csproj index 1199e9bc67..3c42c58c89 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.Common/DevHome.SetupFlow.Common.csproj +++ b/tools/SetupFlow/DevHome.SetupFlow.Common/DevHome.SetupFlow.Common.csproj @@ -15,6 +15,7 @@ all runtime; build; native; contentfiles; analyzers + diff --git a/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs b/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs index ed255d617f..ac383c518e 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; +using System.IO; +using System.Linq; + namespace DevHome.SetupFlow.Common.Helpers; /// @@ -30,5 +34,31 @@ public static class DscHelpers public const string ConfigurationFileYamlExtension = ".dsc.yaml"; - public const string ConfigurationFileWingetExtension = ".winget"; + public const string ConfigurationFileWingetExtension = ".winget"; + + /// + /// Enumerates all files inside the of a repository + /// and returns the most recently modified configuration file. + /// + /// The path to the root of a repository. + /// Path to the configuration file. String.Empty in all other cases. + public static string GetConfigurationFileIfExists(string repositoryRoot) + { + var configurationDirectory = Path.Join(repositoryRoot, ConfigurationFolderName); + if (Directory.Exists(configurationDirectory)) + { + var fileToUse = Directory.EnumerateFiles(configurationDirectory) + .Where(file => file.EndsWith(ConfigurationFileYamlExtension, StringComparison.OrdinalIgnoreCase) || + file.EndsWith(ConfigurationFileWingetExtension, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(configurationFile => File.GetLastWriteTime(configurationFile)) + .FirstOrDefault(); + + if (fileToUse != default) + { + return fileToUse; + } + } + + return string.Empty; + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs index b097920e18..e6c4a69f94 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs @@ -12,6 +12,7 @@ using DevHome.Common.Extensions; using DevHome.Common.Services; using DevHome.Common.TelemetryEvents; +using DevHome.Common.TelemetryEvents.RepositoryManagement; using DevHome.Common.TelemetryEvents.SetupFlow; using DevHome.Database.Services; using DevHome.SetupFlow.Common.Helpers; @@ -45,6 +46,8 @@ public partial class CloneRepoTask : ObservableObject, ISetupTask private readonly IRepositoryProvider _repositoryProvider; + private readonly RepositoryManagementDataAccessService _dataAccessService; + public DirectoryInfo CloneLocation => _cloneLocation; /// @@ -173,6 +176,7 @@ public CloneRepoTask( _activityId = activityId; _host = host; _summaryScreenInformation = new CloneRepoSummaryInformationViewModel(host.GetService(), stringResource); + _dataAccessService = _host.GetService(); } /// @@ -181,7 +185,14 @@ public CloneRepoTask( /// /// Repository will be placed here, at _cloneLocation.FullName /// The repository to clone - public CloneRepoTask(IRepositoryProvider repositoryProvider, DirectoryInfo cloneLocation, IRepository repositoryToClone, ISetupFlowStringResource stringResource, string providerName, Guid activityId, IHost host) + public CloneRepoTask( + IRepositoryProvider repositoryProvider, + DirectoryInfo cloneLocation, + IRepository repositoryToClone, + ISetupFlowStringResource stringResource, + string providerName, + Guid activityId, + IHost host) { _cloneLocation = cloneLocation; this.RepositoryToClone = repositoryToClone; @@ -193,6 +204,7 @@ public CloneRepoTask(IRepositoryProvider repositoryProvider, DirectoryInfo clone _activityId = activityId; _host = host; _summaryScreenInformation = new CloneRepoSummaryInformationViewModel(host.GetService(), stringResource); + _dataAccessService = _host.GetService(); } private void SetMessages(IStringResource stringResource) @@ -263,21 +275,12 @@ IAsyncOperation ISetupTask.Execute() } // Search for a configuration file. - var configurationDirectory = Path.Join(_cloneLocation.FullName, DscHelpers.ConfigurationFolderName); - if (Directory.Exists(configurationDirectory)) + var configurationFile = DscHelpers.GetConfigurationFileIfExists(_cloneLocation.FullName); + if (!configurationFile.Equals(string.Empty, StringComparison.OrdinalIgnoreCase)) { - var fileToUse = Directory.EnumerateFiles(configurationDirectory) - .Where(file => file.EndsWith(DscHelpers.ConfigurationFileYamlExtension, StringComparison.OrdinalIgnoreCase) || - file.EndsWith(DscHelpers.ConfigurationFileWingetExtension, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(configurationFile => File.GetLastWriteTime(configurationFile)) - .FirstOrDefault(); - - if (fileToUse != null) - { - _summaryScreenInformation.FilePathAndName = fileToUse; - _summaryScreenInformation.RepoName = RepositoryName; - _summaryScreenInformation.OwningAccount = RepositoryToClone.OwningAccountName ?? string.Empty; - } + _summaryScreenInformation.FilePathAndName = configurationFile; + _summaryScreenInformation.RepoName = RepositoryName; + _summaryScreenInformation.OwningAccount = RepositoryToClone.OwningAccountName ?? string.Empty; } var experimentationService = _host.GetService(); @@ -287,8 +290,7 @@ IAsyncOperation ISetupTask.Execute() { // TODO: Is this the best place to add the repository to the database? // Maybe a "PostExecutionStep" would be nice. - _host.GetService() - .MakeRepository(RepositoryName, CloneLocation.FullName, RepositoryToClone.RepoUri); + var repository = _dataAccessService.MakeRepository(RepositoryName, CloneLocation.FullName, _summaryScreenInformation.FilePathAndName, RepositoryToClone.RepoUri.ToString()); } WasCloningSuccessful = true;