Skip to content

Commit cf9cdca

Browse files
Feature: Git Integration Phase 3 (#12344)
1 parent 656977e commit cf9cdca

File tree

12 files changed

+499
-43
lines changed

12 files changed

+499
-43
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) 2023 Files Community
2+
// Licensed under the MIT License. See the LICENSE.
3+
4+
namespace Files.App.Data.Items
5+
{
6+
public record BranchItem(string Name, bool IsRemote);
7+
}

src/Files.App/Data/Models/DirectoryPropertiesViewModel.cs

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
// Copyright (c) 2023 Files Community
22
// Licensed under the MIT License. See the LICENSE.
33

4+
using System.Windows.Input;
5+
46
namespace Files.App.Data.Models
57
{
68
public class DirectoryPropertiesViewModel : ObservableObject
79
{
8-
public int ActiveBranchIndex { get; private set; }
10+
// The first branch will always be the active one.
11+
public const int ACTIVE_BRANCH_INDEX = 0;
12+
13+
private string? _gitRepositoryPath;
14+
15+
private readonly ObservableCollection<string> _localBranches = new();
916

10-
private string _DirectoryItemCount;
11-
public string DirectoryItemCount
17+
private readonly ObservableCollection<string> _remoteBranches = new();
18+
19+
private string? _DirectoryItemCount;
20+
public string? DirectoryItemCount
1221
{
1322
get => _DirectoryItemCount;
1423
set => SetProperty(ref _DirectoryItemCount, value);
@@ -27,29 +36,68 @@ public int SelectedBranchIndex
2736
get => _SelectedBranchIndex;
2837
set
2938
{
30-
if (SetProperty(ref _SelectedBranchIndex, value) && value != -1 && value != ActiveBranchIndex)
39+
if (SetProperty(ref _SelectedBranchIndex, value) &&
40+
value != -1 &&
41+
(value != ACTIVE_BRANCH_INDEX || !_ShowLocals))
42+
{
3143
CheckoutRequested?.Invoke(this, BranchesNames[value]);
44+
}
3245
}
3346
}
3447

35-
public ObservableCollection<string> BranchesNames { get; } = new();
48+
private bool _ShowLocals = true;
49+
public bool ShowLocals
50+
{
51+
get => _ShowLocals;
52+
set
53+
{
54+
if (SetProperty(ref _ShowLocals, value))
55+
{
56+
OnPropertyChanged(nameof(BranchesNames));
57+
58+
if (value)
59+
SelectedBranchIndex = ACTIVE_BRANCH_INDEX;
60+
}
61+
}
62+
}
63+
64+
public ObservableCollection<string> BranchesNames => _ShowLocals
65+
? _localBranches
66+
: _remoteBranches;
3667

3768
public EventHandler<string>? CheckoutRequested;
3869

39-
public void UpdateGitInfo(bool isGitRepository, string activeBranch, string[] branches)
70+
public ICommand NewBranchCommand { get; }
71+
72+
public DirectoryPropertiesViewModel()
73+
{
74+
NewBranchCommand = new AsyncRelayCommand(()
75+
=> GitHelpers.CreateNewBranch(_gitRepositoryPath!, _localBranches[ACTIVE_BRANCH_INDEX]));
76+
}
77+
78+
public void UpdateGitInfo(bool isGitRepository, string? repositoryPath, BranchItem[] branches)
4079
{
41-
GitBranchDisplayName = isGitRepository
42-
? string.Format("Branch".GetLocalizedResource(), activeBranch)
80+
GitBranchDisplayName = isGitRepository && branches.Any()
81+
? string.Format("Branch".GetLocalizedResource(), branches[ACTIVE_BRANCH_INDEX].Name)
4382
: null;
4483

84+
_gitRepositoryPath = repositoryPath;
85+
ShowLocals = true;
86+
4587
if (isGitRepository)
4688
{
47-
BranchesNames.Clear();
48-
foreach (var name in branches)
49-
BranchesNames.Add(name);
89+
_localBranches.Clear();
90+
_remoteBranches.Clear();
91+
92+
foreach (var branch in branches)
93+
{
94+
if (branch.IsRemote)
95+
_remoteBranches.Add(branch.Name);
96+
else
97+
_localBranches.Add(branch.Name);
98+
}
5099

51-
ActiveBranchIndex = BranchesNames.IndexOf(activeBranch);
52-
SelectedBranchIndex = ActiveBranchIndex;
100+
SelectedBranchIndex = ACTIVE_BRANCH_INDEX;
53101
}
54102
}
55103
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<!-- Copyright (c) 2023 Files Community. Licensed under the MIT License. See the LICENSE. -->
2+
<ContentDialog
3+
x:Class="Files.App.Dialogs.AddBranchDialog"
4+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
5+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
6+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
7+
xmlns:helpers="using:Files.App.Helpers"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
x:Name="AddBranch"
10+
Title="{helpers:ResourceString Name=CreateNewBranch}"
11+
Closing="ContentDialog_Closing"
12+
CornerRadius="{StaticResource OverlayCornerRadius}"
13+
DefaultButton="Primary"
14+
IsPrimaryButtonEnabled="{x:Bind ViewModel.IsBranchValid, Mode=OneWay}"
15+
PrimaryButtonStyle="{StaticResource AccentButtonStyle}"
16+
PrimaryButtonText="{helpers:ResourceString Name=Create}"
17+
RequestedTheme="{x:Bind helpers:ThemeHelper.RootTheme}"
18+
SecondaryButtonText="{helpers:ResourceString Name=Cancel}"
19+
Style="{StaticResource DefaultContentDialogStyle}"
20+
mc:Ignorable="d">
21+
22+
<StackPanel Width="440" Spacing="4">
23+
<!-- Branch Name -->
24+
<Grid
25+
Padding="12"
26+
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
27+
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
28+
BorderThickness="1"
29+
ColumnSpacing="8"
30+
CornerRadius="4">
31+
<Grid.ColumnDefinitions>
32+
<ColumnDefinition Width="*" />
33+
<ColumnDefinition Width="Auto" />
34+
</Grid.ColumnDefinitions>
35+
<TextBlock
36+
Grid.Column="0"
37+
VerticalAlignment="Center"
38+
Text="{helpers:ResourceString Name=Name}" />
39+
<TextBox
40+
x:Name="BranchNameBox"
41+
Grid.Column="1"
42+
Width="260"
43+
PlaceholderText="{helpers:ResourceString Name=EnterName}"
44+
Text="{x:Bind ViewModel.NewBranchName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
45+
<TextBox.Resources>
46+
<TeachingTip
47+
x:Name="InvalidNameWarning"
48+
Title="{helpers:ResourceString Name=InvalidBranchName}"
49+
IsOpen="{x:Bind ViewModel.ShowWarningTip, Mode=OneWay}"
50+
PreferredPlacement="Bottom"
51+
Target="{x:Bind BranchNameBox}" />
52+
</TextBox.Resources>
53+
</TextBox>
54+
</Grid>
55+
56+
<!-- Branch Options -->
57+
<Grid
58+
Padding="12"
59+
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
60+
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
61+
BorderThickness="1"
62+
ColumnSpacing="8"
63+
CornerRadius="4"
64+
RowSpacing="12">
65+
<Grid.ColumnDefinitions>
66+
<ColumnDefinition Width="*" />
67+
<ColumnDefinition Width="Auto" />
68+
</Grid.ColumnDefinitions>
69+
<Grid.RowDefinitions>
70+
<RowDefinition Height="Auto" />
71+
<RowDefinition Height="Auto" />
72+
</Grid.RowDefinitions>
73+
74+
<!-- Based On -->
75+
<TextBlock
76+
Grid.Row="0"
77+
Grid.Column="0"
78+
VerticalAlignment="Center"
79+
Text="{helpers:ResourceString Name=BasedOn}" />
80+
<ComboBox
81+
x:Name="BranchBox"
82+
Grid.Row="0"
83+
Grid.Column="1"
84+
Width="160"
85+
ItemsSource="{x:Bind ViewModel.Branches}"
86+
SelectedItem="{x:Bind ViewModel.BasedOn, Mode=TwoWay}" />
87+
88+
<!-- Switch To Branch -->
89+
<TextBlock
90+
Grid.Row="1"
91+
Grid.Column="0"
92+
VerticalAlignment="Center"
93+
Text="{helpers:ResourceString Name=SwitchToNewBranch}" />
94+
<ToggleSwitch
95+
Grid.Row="1"
96+
Grid.Column="1"
97+
MinWidth="0"
98+
HorizontalAlignment="Right"
99+
IsOn="{x:Bind ViewModel.Checkout, Mode=TwoWay}" />
100+
</Grid>
101+
</StackPanel>
102+
</ContentDialog>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2023 Files Community
2+
// Licensed under the MIT License. See the LICENSE.
3+
4+
using Files.App.ViewModels.Dialogs;
5+
using Files.Backend.ViewModels.Dialogs;
6+
using Microsoft.UI.Xaml.Controls;
7+
8+
// The Content Dialog item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238
9+
10+
namespace Files.App.Dialogs
11+
{
12+
public sealed partial class AddBranchDialog : ContentDialog, IDialog<AddBranchDialogViewModel>
13+
{
14+
public AddBranchDialogViewModel ViewModel
15+
{
16+
get => (AddBranchDialogViewModel)DataContext;
17+
set => DataContext = value;
18+
}
19+
20+
public AddBranchDialog()
21+
{
22+
InitializeComponent();
23+
}
24+
25+
public new async Task<DialogResult> ShowAsync() => (DialogResult)await base.ShowAsync();
26+
27+
private void ContentDialog_Closing(ContentDialog _, ContentDialogClosingEventArgs e)
28+
{
29+
InvalidNameWarning.IsOpen = false;
30+
Closing -= ContentDialog_Closing;
31+
}
32+
}
33+
}

src/Files.App/Helpers/GitHelpers.cs

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22
// Licensed under the MIT License. See the LICENSE.
33

44
using Files.App.Filesystem.StorageItems;
5+
using Files.App.ViewModels.Dialogs;
6+
using Files.Backend.Services;
57
using LibGit2Sharp;
68
using Microsoft.AppCenter.Analytics;
9+
using System.Text.RegularExpressions;
710

811
namespace Files.App.Helpers
912
{
10-
public static class GitHelpers
13+
internal static class GitHelpers
1114
{
15+
private const string BRANCH_NAME_PATTERN = @"^(?!/)(?!.*//)[^\000-\037\177 ~^:?*[]+(?!.*\.\.)(?!.*@\{)(?!.*\\)(?<!/\.)(?<!\.)(?<!/)(?<!\.lock)$";
16+
17+
private const int END_OF_ORIGIN_PREFIX = 7;
18+
1219
public static string? GetGitRepositoryPath(string? path, string root)
1320
{
1421
if (root.EndsWith('\\'))
@@ -34,17 +41,18 @@ public static class GitHelpers
3441
}
3542
}
3643

37-
public static string[] GetLocalBranchesNames(string? path)
44+
public static BranchItem[] GetBranchesNames(string? path)
3845
{
3946
if (string.IsNullOrWhiteSpace(path) || !Repository.IsValid(path))
40-
return Array.Empty<string>();
47+
return Array.Empty<BranchItem>();
4148

4249
using var repository = new Repository(path);
4350
return repository.Branches
44-
.Where(b => !b.IsRemote)
51+
.Where(b => !b.IsRemote || b.RemoteName == "origin")
4552
.OrderByDescending(b => b.IsCurrentRepositoryHead)
53+
.ThenBy(b => b.IsRemote)
4654
.ThenByDescending(b => b.Tip.Committer.When)
47-
.Select(b => b.FriendlyName)
55+
.Select(b => new BranchItem(b.FriendlyName, b.IsRemote))
4856
.ToArray();
4957
}
5058

@@ -79,21 +87,87 @@ public static async Task<bool> Checkout(string? repositoryPath, string? branch)
7987
break;
8088
case GitCheckoutOptions.BringChanges:
8189
case GitCheckoutOptions.StashChanges:
82-
repository.Stashes.Add(repository.Config.BuildSignature(DateTimeOffset.Now));
90+
var signature = repository.Config.BuildSignature(DateTimeOffset.Now);
91+
if (signature is null)
92+
return false;
93+
94+
repository.Stashes.Add(signature);
8395

8496
isBringingChanges = resolveConflictOption is GitCheckoutOptions.BringChanges;
8597
break;
8698
}
8799
}
88100

89-
LibGit2Sharp.Commands.Checkout(repository, checkoutBranch, options);
101+
try
102+
{
103+
if (checkoutBranch.IsRemote)
104+
CheckoutRemoteBranch(repository, checkoutBranch);
105+
else
106+
LibGit2Sharp.Commands.Checkout(repository, checkoutBranch, options);
90107

91-
if (isBringingChanges)
108+
if (isBringingChanges)
109+
{
110+
var lastStashIndex = repository.Stashes.Count() - 1;
111+
repository.Stashes.Pop(lastStashIndex, new StashApplyOptions());
112+
}
113+
return true;
114+
}
115+
catch (Exception)
92116
{
93-
var lastStashIndex = repository.Stashes.Count() - 1;
94-
repository.Stashes.Pop(lastStashIndex, new StashApplyOptions());
117+
return false;
95118
}
96-
return true;
119+
}
120+
121+
public static async Task CreateNewBranch(string repositoryPath, string activeBranch)
122+
{
123+
var viewModel = new AddBranchDialogViewModel(repositoryPath, activeBranch);
124+
var dialog = Ioc.Default.GetRequiredService<IDialogService>().GetDialog(viewModel);
125+
126+
var result = await dialog.TryShowAsync();
127+
128+
if (result != DialogResult.Primary)
129+
return;
130+
131+
using var repository = new Repository(repositoryPath);
132+
133+
if (repository.Head.FriendlyName.Equals(viewModel.NewBranchName) ||
134+
await Checkout(repositoryPath, viewModel.BasedOn))
135+
{
136+
Analytics.TrackEvent($"Triggered git branch");
137+
138+
repository.CreateBranch(viewModel.NewBranchName);
139+
140+
if (viewModel.Checkout)
141+
await Checkout(repositoryPath, viewModel.NewBranchName);
142+
}
143+
}
144+
145+
public static bool ValidateBranchNameForRepository(string branchName, string repositoryPath)
146+
{
147+
if (string.IsNullOrEmpty(branchName) || !Repository.IsValid(repositoryPath))
148+
return false;
149+
150+
var nameValidator = new Regex(BRANCH_NAME_PATTERN);
151+
if (!nameValidator.IsMatch(branchName))
152+
return false;
153+
154+
using var repository = new Repository(repositoryPath);
155+
return !repository.Branches.Any(branch =>
156+
branch.FriendlyName.Equals(branchName, StringComparison.OrdinalIgnoreCase));
157+
}
158+
159+
private static void CheckoutRemoteBranch(Repository repository, Branch branch)
160+
{
161+
var uniqueName = branch.FriendlyName.Substring(END_OF_ORIGIN_PREFIX);
162+
163+
var discriminator = 0;
164+
while (repository.Branches.Any(b => !b.IsRemote && b.FriendlyName == uniqueName))
165+
uniqueName = $"{branch.FriendlyName}_{++discriminator}";
166+
167+
var newBranch = repository.CreateBranch(uniqueName, branch.Tip);
168+
repository.Branches.Update(newBranch, b => b.TrackedBranch = branch.CanonicalName);
169+
170+
LibGit2Sharp.Commands.Checkout(repository, newBranch);
97171
}
98172
}
99173
}

src/Files.App/ServicesImplementation/DialogService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public DialogService()
3636
{ typeof(DecompressArchiveDialogViewModel), () => new DecompressArchiveDialog() },
3737
{ typeof(SettingsDialogViewModel), () => new SettingsDialog() },
3838
{ typeof(CreateShortcutDialogViewModel), () => new CreateShortcutDialog() },
39-
{ typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() }
39+
{ typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() },
40+
{ typeof(AddBranchDialogViewModel), () => new AddBranchDialog() }
4041
};
4142
}
4243

0 commit comments

Comments
 (0)