From cf9cdcaa7dc10f442ed0fe8bc21e87b142071b50 Mon Sep 17 00:00:00 2001 From: Filippo Ferrario <102259289+ferrariofilippo@users.noreply.github.com> Date: Sun, 21 May 2023 16:25:04 +0200 Subject: [PATCH] Feature: Git Integration Phase 3 (#12344) --- src/Files.App/Data/Items/BranchItem.cs | 7 ++ .../Models/DirectoryPropertiesViewModel.cs | 74 +++++++++--- src/Files.App/Dialogs/AddBranchDialog.xaml | 102 ++++++++++++++++ src/Files.App/Dialogs/AddBranchDialog.xaml.cs | 33 ++++++ src/Files.App/Helpers/GitHelpers.cs | 96 +++++++++++++-- .../ServicesImplementation/DialogService.cs | 3 +- src/Files.App/Strings/en-US/Resources.resw | 21 ++++ .../UserControls/StatusBarControl.xaml | 110 ++++++++++++++++-- .../UserControls/StatusBarControl.xaml.cs | 3 +- .../Dialogs/AddBranchDialogViewModel.cs | 57 +++++++++ src/Files.App/Views/HomePage.xaml.cs | 2 +- src/Files.App/Views/Shells/BaseShellPage.cs | 34 ++++-- 12 files changed, 499 insertions(+), 43 deletions(-) create mode 100644 src/Files.App/Data/Items/BranchItem.cs create mode 100644 src/Files.App/Dialogs/AddBranchDialog.xaml create mode 100644 src/Files.App/Dialogs/AddBranchDialog.xaml.cs create mode 100644 src/Files.App/ViewModels/Dialogs/AddBranchDialogViewModel.cs diff --git a/src/Files.App/Data/Items/BranchItem.cs b/src/Files.App/Data/Items/BranchItem.cs new file mode 100644 index 000000000000..e9a9da6358b3 --- /dev/null +++ b/src/Files.App/Data/Items/BranchItem.cs @@ -0,0 +1,7 @@ +// Copyright (c) 2023 Files Community +// Licensed under the MIT License. See the LICENSE. + +namespace Files.App.Data.Items +{ + public record BranchItem(string Name, bool IsRemote); +} diff --git a/src/Files.App/Data/Models/DirectoryPropertiesViewModel.cs b/src/Files.App/Data/Models/DirectoryPropertiesViewModel.cs index 6d314147f7d9..98f8561d8912 100644 --- a/src/Files.App/Data/Models/DirectoryPropertiesViewModel.cs +++ b/src/Files.App/Data/Models/DirectoryPropertiesViewModel.cs @@ -1,14 +1,23 @@ // Copyright (c) 2023 Files Community // Licensed under the MIT License. See the LICENSE. +using System.Windows.Input; + namespace Files.App.Data.Models { public class DirectoryPropertiesViewModel : ObservableObject { - public int ActiveBranchIndex { get; private set; } + // The first branch will always be the active one. + public const int ACTIVE_BRANCH_INDEX = 0; + + private string? _gitRepositoryPath; + + private readonly ObservableCollection _localBranches = new(); - private string _DirectoryItemCount; - public string DirectoryItemCount + private readonly ObservableCollection _remoteBranches = new(); + + private string? _DirectoryItemCount; + public string? DirectoryItemCount { get => _DirectoryItemCount; set => SetProperty(ref _DirectoryItemCount, value); @@ -27,29 +36,68 @@ public int SelectedBranchIndex get => _SelectedBranchIndex; set { - if (SetProperty(ref _SelectedBranchIndex, value) && value != -1 && value != ActiveBranchIndex) + if (SetProperty(ref _SelectedBranchIndex, value) && + value != -1 && + (value != ACTIVE_BRANCH_INDEX || !_ShowLocals)) + { CheckoutRequested?.Invoke(this, BranchesNames[value]); + } } } - public ObservableCollection BranchesNames { get; } = new(); + private bool _ShowLocals = true; + public bool ShowLocals + { + get => _ShowLocals; + set + { + if (SetProperty(ref _ShowLocals, value)) + { + OnPropertyChanged(nameof(BranchesNames)); + + if (value) + SelectedBranchIndex = ACTIVE_BRANCH_INDEX; + } + } + } + + public ObservableCollection BranchesNames => _ShowLocals + ? _localBranches + : _remoteBranches; public EventHandler? CheckoutRequested; - public void UpdateGitInfo(bool isGitRepository, string activeBranch, string[] branches) + public ICommand NewBranchCommand { get; } + + public DirectoryPropertiesViewModel() + { + NewBranchCommand = new AsyncRelayCommand(() + => GitHelpers.CreateNewBranch(_gitRepositoryPath!, _localBranches[ACTIVE_BRANCH_INDEX])); + } + + public void UpdateGitInfo(bool isGitRepository, string? repositoryPath, BranchItem[] branches) { - GitBranchDisplayName = isGitRepository - ? string.Format("Branch".GetLocalizedResource(), activeBranch) + GitBranchDisplayName = isGitRepository && branches.Any() + ? string.Format("Branch".GetLocalizedResource(), branches[ACTIVE_BRANCH_INDEX].Name) : null; + _gitRepositoryPath = repositoryPath; + ShowLocals = true; + if (isGitRepository) { - BranchesNames.Clear(); - foreach (var name in branches) - BranchesNames.Add(name); + _localBranches.Clear(); + _remoteBranches.Clear(); + + foreach (var branch in branches) + { + if (branch.IsRemote) + _remoteBranches.Add(branch.Name); + else + _localBranches.Add(branch.Name); + } - ActiveBranchIndex = BranchesNames.IndexOf(activeBranch); - SelectedBranchIndex = ActiveBranchIndex; + SelectedBranchIndex = ACTIVE_BRANCH_INDEX; } } } diff --git a/src/Files.App/Dialogs/AddBranchDialog.xaml b/src/Files.App/Dialogs/AddBranchDialog.xaml new file mode 100644 index 000000000000..7689dee1fe93 --- /dev/null +++ b/src/Files.App/Dialogs/AddBranchDialog.xaml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Files.App/Dialogs/AddBranchDialog.xaml.cs b/src/Files.App/Dialogs/AddBranchDialog.xaml.cs new file mode 100644 index 000000000000..a725239451e1 --- /dev/null +++ b/src/Files.App/Dialogs/AddBranchDialog.xaml.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2023 Files Community +// Licensed under the MIT License. See the LICENSE. + +using Files.App.ViewModels.Dialogs; +using Files.Backend.ViewModels.Dialogs; +using Microsoft.UI.Xaml.Controls; + +// The Content Dialog item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238 + +namespace Files.App.Dialogs +{ + public sealed partial class AddBranchDialog : ContentDialog, IDialog + { + public AddBranchDialogViewModel ViewModel + { + get => (AddBranchDialogViewModel)DataContext; + set => DataContext = value; + } + + public AddBranchDialog() + { + InitializeComponent(); + } + + public new async Task ShowAsync() => (DialogResult)await base.ShowAsync(); + + private void ContentDialog_Closing(ContentDialog _, ContentDialogClosingEventArgs e) + { + InvalidNameWarning.IsOpen = false; + Closing -= ContentDialog_Closing; + } + } +} diff --git a/src/Files.App/Helpers/GitHelpers.cs b/src/Files.App/Helpers/GitHelpers.cs index f831daf5ffbb..fb0fb578ce10 100644 --- a/src/Files.App/Helpers/GitHelpers.cs +++ b/src/Files.App/Helpers/GitHelpers.cs @@ -2,13 +2,20 @@ // Licensed under the MIT License. See the LICENSE. using Files.App.Filesystem.StorageItems; +using Files.App.ViewModels.Dialogs; +using Files.Backend.Services; using LibGit2Sharp; using Microsoft.AppCenter.Analytics; +using System.Text.RegularExpressions; namespace Files.App.Helpers { - public static class GitHelpers + internal static class GitHelpers { + private const string BRANCH_NAME_PATTERN = @"^(?!/)(?!.*//)[^\000-\037\177 ~^:?*[]+(?!.*\.\.)(?!.*@\{)(?!.*\\)(?(); + return Array.Empty(); using var repository = new Repository(path); return repository.Branches - .Where(b => !b.IsRemote) + .Where(b => !b.IsRemote || b.RemoteName == "origin") .OrderByDescending(b => b.IsCurrentRepositoryHead) + .ThenBy(b => b.IsRemote) .ThenByDescending(b => b.Tip.Committer.When) - .Select(b => b.FriendlyName) + .Select(b => new BranchItem(b.FriendlyName, b.IsRemote)) .ToArray(); } @@ -79,21 +87,87 @@ public static async Task Checkout(string? repositoryPath, string? branch) break; case GitCheckoutOptions.BringChanges: case GitCheckoutOptions.StashChanges: - repository.Stashes.Add(repository.Config.BuildSignature(DateTimeOffset.Now)); + var signature = repository.Config.BuildSignature(DateTimeOffset.Now); + if (signature is null) + return false; + + repository.Stashes.Add(signature); isBringingChanges = resolveConflictOption is GitCheckoutOptions.BringChanges; break; } } - LibGit2Sharp.Commands.Checkout(repository, checkoutBranch, options); + try + { + if (checkoutBranch.IsRemote) + CheckoutRemoteBranch(repository, checkoutBranch); + else + LibGit2Sharp.Commands.Checkout(repository, checkoutBranch, options); - if (isBringingChanges) + if (isBringingChanges) + { + var lastStashIndex = repository.Stashes.Count() - 1; + repository.Stashes.Pop(lastStashIndex, new StashApplyOptions()); + } + return true; + } + catch (Exception) { - var lastStashIndex = repository.Stashes.Count() - 1; - repository.Stashes.Pop(lastStashIndex, new StashApplyOptions()); + return false; } - return true; + } + + public static async Task CreateNewBranch(string repositoryPath, string activeBranch) + { + var viewModel = new AddBranchDialogViewModel(repositoryPath, activeBranch); + var dialog = Ioc.Default.GetRequiredService().GetDialog(viewModel); + + var result = await dialog.TryShowAsync(); + + if (result != DialogResult.Primary) + return; + + using var repository = new Repository(repositoryPath); + + if (repository.Head.FriendlyName.Equals(viewModel.NewBranchName) || + await Checkout(repositoryPath, viewModel.BasedOn)) + { + Analytics.TrackEvent($"Triggered git branch"); + + repository.CreateBranch(viewModel.NewBranchName); + + if (viewModel.Checkout) + await Checkout(repositoryPath, viewModel.NewBranchName); + } + } + + public static bool ValidateBranchNameForRepository(string branchName, string repositoryPath) + { + if (string.IsNullOrEmpty(branchName) || !Repository.IsValid(repositoryPath)) + return false; + + var nameValidator = new Regex(BRANCH_NAME_PATTERN); + if (!nameValidator.IsMatch(branchName)) + return false; + + using var repository = new Repository(repositoryPath); + return !repository.Branches.Any(branch => + branch.FriendlyName.Equals(branchName, StringComparison.OrdinalIgnoreCase)); + } + + private static void CheckoutRemoteBranch(Repository repository, Branch branch) + { + var uniqueName = branch.FriendlyName.Substring(END_OF_ORIGIN_PREFIX); + + var discriminator = 0; + while (repository.Branches.Any(b => !b.IsRemote && b.FriendlyName == uniqueName)) + uniqueName = $"{branch.FriendlyName}_{++discriminator}"; + + var newBranch = repository.CreateBranch(uniqueName, branch.Tip); + repository.Branches.Update(newBranch, b => b.TrackedBranch = branch.CanonicalName); + + LibGit2Sharp.Commands.Checkout(repository, newBranch); } } } diff --git a/src/Files.App/ServicesImplementation/DialogService.cs b/src/Files.App/ServicesImplementation/DialogService.cs index bd401cf07f73..428a761413c7 100644 --- a/src/Files.App/ServicesImplementation/DialogService.cs +++ b/src/Files.App/ServicesImplementation/DialogService.cs @@ -36,7 +36,8 @@ public DialogService() { typeof(DecompressArchiveDialogViewModel), () => new DecompressArchiveDialog() }, { typeof(SettingsDialogViewModel), () => new SettingsDialog() }, { typeof(CreateShortcutDialogViewModel), () => new CreateShortcutDialog() }, - { typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() } + { typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() }, + { typeof(AddBranchDialogViewModel), () => new AddBranchDialog() } }; } diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 6306b51698f6..3efef1d241bb 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -3286,6 +3286,21 @@ New branch + + Invalid branch name + + + Create branch + + + Create Branch + + + Based on + + + Switch to new branch + Create a folder with the currently selected item(s) @@ -3295,4 +3310,10 @@ Open properties window + + Locals + + + Remotes + \ No newline at end of file diff --git a/src/Files.App/UserControls/StatusBarControl.xaml b/src/Files.App/UserControls/StatusBarControl.xaml index e71d3a25bf54..f214e7c3dd37 100644 --- a/src/Files.App/UserControls/StatusBarControl.xaml +++ b/src/Files.App/UserControls/StatusBarControl.xaml @@ -13,6 +13,90 @@ + + @@ -58,7 +142,7 @@ x:Name="GitBranch" x:Load="{x:Bind DirectoryPropertiesViewModel.GitBranchDisplayName, Mode=OneWay, Converter={StaticResource NullToFalseConverter}}" Background="Transparent" - BorderThickness="0" + BorderBrush="Transparent" Content="{x:Bind DirectoryPropertiesViewModel.GitBranchDisplayName, Mode=OneWay}"> @@ -74,15 +158,23 @@ - - + + Orientation="Horizontal"> + + +