[Hosts] Improved duplicate hosts finding (#24294)

* improved duplicate hosts finding

* improved filters with AdvancedCollectionView

* cancel FindDuplicates when file is reloaded
This commit is contained in:
Davide Giacometti
2023-02-27 19:11:57 +01:00
committed by GitHub
parent 0d9b797ef0
commit 3e651b8de6
14 changed files with 294 additions and 87 deletions

View File

@@ -28,7 +28,7 @@ namespace Hosts.Tests
[DataRow("# # \t10.1.1.1 host#comment", "10.1.1.1", "host", "comment", false)]
public void Valid_Entry_SingleHost(string line, string address, string host, string comment, bool active)
{
var entry = new Entry(line);
var entry = new Entry(0, line);
Assert.AreEqual(entry.Address, address);
Assert.AreEqual(entry.Hosts, host);
@@ -52,7 +52,7 @@ namespace Hosts.Tests
[DataRow("#10.1.1.1 host host.local#comment", "10.1.1.1", "host host.local", "comment", false)]
public void Valid_Entry_MultipleHosts(string line, string address, string host, string comment, bool active)
{
var entry = new Entry(line);
var entry = new Entry(0, line);
Assert.AreEqual(entry.Address, address);
Assert.AreEqual(entry.Hosts, host);
@@ -76,7 +76,7 @@ namespace Hosts.Tests
[DataRow("host 10.1.1.1")]
public void Not_Valid_Entry(string line)
{
var entry = new Entry(line);
var entry = new Entry(0, line);
Assert.IsFalse(entry.Valid);
}
}

View File

@@ -70,7 +70,7 @@ namespace Hosts.Tests
fileSystem.AddFile(service.HostsFilePath, new MockFileData(content));
var (_, entries) = await service.ReadAsync();
entries.Add(new Entry("10.1.1.30", "host30 host30.local", "new entry", false));
entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false));
await service.WriteAsync(string.Empty, entries);
var result = fileSystem.GetFile(service.HostsFilePath);

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Linq.Expressions;
namespace Hosts.Helpers
{
// https://stackoverflow.com/a/22569086
public static class ExpressionExtensions
{
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> expr2)
{
var secondBody = expr2.Body.Replace(expr2.Parameters[0], expr1.Parameters[0]);
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(expr1.Body, secondBody), expr1.Parameters);
}
public static Expression Replace(this Expression expression, Expression searchEx, Expression replaceEx)
{
return new ReplaceVisitor(searchEx, replaceEx).Visit(expression);
}
internal class ReplaceVisitor : ExpressionVisitor
{
private readonly Expression _from;
private readonly Expression _to;
public ReplaceVisitor(Expression from, Expression to)
{
_from = from;
_to = to;
}
public override Expression Visit(Expression node)
{
return node == _from ? _to : base.Visit(node);
}
}
}
}

View File

@@ -82,7 +82,7 @@ namespace Hosts.Helpers
continue;
}
var entry = new Entry(line);
var entry = new Entry(i, line);
if (entry.Valid)
{

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Hosts.Models
{
public enum AddressType
{
Invalid = 0,
IPv4 = 1,
IPv6 = 2,
}
}

View File

@@ -22,6 +22,7 @@ namespace Hosts.Models
set
{
SetProperty(ref _address, value);
SetAddressType();
OnPropertyChanged(nameof(Valid));
}
}
@@ -54,22 +55,28 @@ namespace Hosts.Models
[ObservableProperty]
private bool _duplicate;
public bool Valid => ValidationHelper.ValidHosts(_hosts) && (ValidationHelper.ValidIPv4(_address) || ValidationHelper.ValidIPv6(_address));
public bool Valid => ValidationHelper.ValidHosts(_hosts) && Type != AddressType.Invalid;
public AddressType Type { get; private set; }
public string[] SplittedHosts { get; private set; }
public int Id { get; set; }
public Entry()
{
}
public Entry(string line)
public Entry(int id, string line)
{
Id = id;
_line = line.Trim();
Parse();
}
public Entry(string address, string hosts, string comment, bool active)
public Entry(int id, string address, string hosts, string comment, bool active)
{
Id = id;
Address = address.Trim();
Hosts = hosts.Trim();
Comment = comment.Trim();
@@ -151,5 +158,21 @@ namespace Hosts.Models
{
return _line;
}
private void SetAddressType()
{
if (ValidationHelper.ValidIPv4(_address))
{
Type = AddressType.IPv4;
}
else if (ValidationHelper.ValidIPv6(_address))
{
Type = AddressType.IPv6;
}
else
{
Type = AddressType.Invalid;
}
}
}
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Settings.UI.Library.Enumerations;
namespace Hosts.Settings
@@ -10,6 +11,10 @@ namespace Hosts.Settings
{
public bool ShowStartupWarning { get; }
public bool LoopbackDuplicates { get; }
public AdditionalLinesPosition AdditionalLinesPosition { get; }
event EventHandler LoopbackDuplicatesChanged;
}
}

View File

@@ -22,12 +22,28 @@ namespace Hosts.Settings
public bool ShowStartupWarning { get; private set; }
private bool _loopbackDuplicates;
public bool LoopbackDuplicates
{
get => _loopbackDuplicates;
set
{
if (_loopbackDuplicates != value)
{
_loopbackDuplicates = value;
LoopbackDuplicatesChanged?.Invoke(this, EventArgs.Empty);
}
}
}
public AdditionalLinesPosition AdditionalLinesPosition { get; private set; }
public UserSettings()
{
_settingsUtils = new SettingsUtils();
ShowStartupWarning = true;
LoopbackDuplicates = false;
AdditionalLinesPosition = AdditionalLinesPosition.Top;
LoadSettingsFromJson();
@@ -35,6 +51,8 @@ namespace Hosts.Settings
_watcher = Helper.GetFileWatcher(HostsModuleName, "settings.json", () => LoadSettingsFromJson());
}
public event EventHandler LoopbackDuplicatesChanged;
private void LoadSettingsFromJson()
{
lock (_loadingSettingsLock)
@@ -60,6 +78,7 @@ namespace Hosts.Settings
{
ShowStartupWarning = settings.Properties.ShowStartupWarning;
AdditionalLinesPosition = settings.Properties.AdditionalLinesPosition;
LoopbackDuplicates = settings.Properties.LoopbackDuplicates;
}
retry = false;

View File

@@ -6,14 +6,17 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.UI;
using Hosts.Helpers;
using Hosts.Models;
using Hosts.Settings;
using Microsoft.UI.Dispatching;
namespace Hosts.ViewModels
@@ -21,8 +24,18 @@ namespace Hosts.ViewModels
public partial class MainViewModel : ObservableObject, IDisposable
{
private readonly IHostsService _hostsService;
private readonly IUserSettings _userSettings;
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private readonly string[] _loopbackAddresses =
{
"127.0.0.1",
"::1",
"0:0:0:0:0:0:0:1",
};
private bool _readingHosts;
private bool _disposed;
private CancellationTokenSource _tokenSource;
[ObservableProperty]
private Entry _selected;
@@ -33,9 +46,6 @@ namespace Hosts.ViewModels
[ObservableProperty]
private bool _fileChanged;
[ObservableProperty]
private bool _filtered;
[ObservableProperty]
private string _addressFilter;
@@ -45,38 +55,40 @@ namespace Hosts.ViewModels
[ObservableProperty]
private string _commentFilter;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Entries))]
private bool _showOnlyDuplicates;
[ObservableProperty]
private string _additionalLines;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private bool _filtered;
private bool _showOnlyDuplicates;
public bool ShowOnlyDuplicates
{
get => _showOnlyDuplicates;
set
{
SetProperty(ref _showOnlyDuplicates, value);
ApplyFilters();
}
}
private ObservableCollection<Entry> _entries;
public ObservableCollection<Entry> Entries => _filtered || _showOnlyDuplicates ? GetFilteredEntries() : _entries;
public AdvancedCollectionView Entries { get; set; }
public ICommand ReadHostsCommand => new RelayCommand(ReadHosts);
public int NextId => _entries.Max(e => e.Id) + 1;
public ICommand ApplyFiltersCommand => new RelayCommand(ApplyFilters);
public ICommand ClearFiltersCommand => new RelayCommand(ClearFilters);
public ICommand OpenSettingsCommand => new RelayCommand(OpenSettings);
public ICommand OpenHostsFileCommand => new RelayCommand(OpenHostsFile);
public MainViewModel(IHostsService hostService)
public MainViewModel(IHostsService hostService, IUserSettings userSettings)
{
_hostsService = hostService;
_userSettings = userSettings;
_hostsService.FileChanged += (s, e) =>
{
_dispatcherQueue.TryEnqueue(() => FileChanged = true);
};
_hostsService.FileChanged += (s, e) => _dispatcherQueue.TryEnqueue(() => FileChanged = true);
_userSettings.LoopbackDuplicatesChanged += (s, e) => ReadHosts();
}
public void Add(Entry entry)
@@ -85,12 +97,11 @@ namespace Hosts.ViewModels
_entries.Add(entry);
FindDuplicates(entry.Address, entry.SplittedHosts);
OnPropertyChanged(nameof(Entries));
}
public void Update(int index, Entry entry)
{
var existingEntry = Entries.ElementAt(index);
var existingEntry = Entries[index] as Entry;
var oldAddress = existingEntry.Address;
var oldHosts = existingEntry.SplittedHosts;
@@ -101,7 +112,6 @@ namespace Hosts.ViewModels
FindDuplicates(oldAddress, oldHosts);
FindDuplicates(entry.Address, entry.SplittedHosts);
OnPropertyChanged(nameof(Entries));
}
public void DeleteSelected()
@@ -111,7 +121,6 @@ namespace Hosts.ViewModels
_entries.Remove(Selected);
FindDuplicates(address, hosts);
OnPropertyChanged(nameof(Entries));
}
public void UpdateAdditionalLines(string lines)
@@ -125,13 +134,39 @@ namespace Hosts.ViewModels
});
}
public void Move(int oldIndex, int newIndex)
{
if (Filtered)
{
return;
}
// Swap the IDs
var entry1 = _entries[oldIndex];
var entry2 = _entries[newIndex];
(entry2.Id, entry1.Id) = (entry1.Id, entry2.Id);
// Move entries in the UI
_entries.Move(oldIndex, newIndex);
}
[RelayCommand]
public void ReadHosts()
{
if (_readingHosts)
{
return;
}
_dispatcherQueue.TryEnqueue(() =>
{
FileChanged = false;
IsLoading = true;
});
Task.Run(async () =>
{
_readingHosts = true;
(_additionalLines, var entries) = await _hostsService.ReadAsync();
await _dispatcherQueue.EnqueueAsync(() =>
@@ -144,34 +179,66 @@ namespace Hosts.ViewModels
}
_entries.CollectionChanged += Entries_CollectionChanged;
Entries = new AdvancedCollectionView(_entries, true);
Entries.SortDescriptions.Add(new SortDescription(nameof(Entry.Id), SortDirection.Ascending));
ApplyFilters();
OnPropertyChanged(nameof(Entries));
IsLoading = false;
});
_readingHosts = false;
FindDuplicates();
_tokenSource?.Cancel();
_tokenSource = new CancellationTokenSource();
FindDuplicates(_tokenSource.Token);
});
}
[RelayCommand]
public void ApplyFilters()
{
if (_entries == null)
var expressions = new List<Expression<Func<object, bool>>>(4);
if (!string.IsNullOrWhiteSpace(_addressFilter))
{
return;
expressions.Add(e => ((Entry)e).Address.Contains(_addressFilter, StringComparison.OrdinalIgnoreCase));
}
Filtered = !string.IsNullOrWhiteSpace(_addressFilter)
|| !string.IsNullOrWhiteSpace(_hostsFilter)
|| !string.IsNullOrWhiteSpace(_commentFilter);
OnPropertyChanged(nameof(Entries));
if (!string.IsNullOrWhiteSpace(_hostsFilter))
{
expressions.Add(e => ((Entry)e).Hosts.Contains(_hostsFilter, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(_commentFilter))
{
expressions.Add(e => ((Entry)e).Comment.Contains(_commentFilter, StringComparison.OrdinalIgnoreCase));
}
if (_showOnlyDuplicates)
{
expressions.Add(e => ((Entry)e).Duplicate);
}
Expression<Func<object, bool>> filterExpression = null;
foreach (var e in expressions)
{
filterExpression = filterExpression == null ? e : filterExpression.And(e);
}
Filtered = filterExpression != null;
Entries.Filter = Filtered ? filterExpression.Compile().Invoke : null;
Entries.RefreshFilter();
}
[RelayCommand]
public void ClearFilters()
{
AddressFilter = null;
HostsFilter = null;
CommentFilter = null;
ShowOnlyDuplicates = false;
Entries.Filter = null;
Entries.RefreshFilter();
}
public async Task PingSelectedAsync()
@@ -183,11 +250,13 @@ namespace Hosts.ViewModels
selected.Pinging = false;
}
[RelayCommand]
public void OpenSettings()
{
SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.Hosts);
}
[RelayCommand]
public void OpenHostsFile()
{
_hostsService.OpenHostsFile();
@@ -201,6 +270,14 @@ namespace Hosts.ViewModels
private void Entry_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (Filtered && (e.PropertyName == nameof(Entry.Hosts)
|| e.PropertyName == nameof(Entry.Address)
|| e.PropertyName == nameof(Entry.Comment)
|| e.PropertyName == nameof(Entry.Duplicate)))
{
Entries.RefreshFilter();
}
// Ping and duplicate should't trigger a file save
if (e.PropertyName == nameof(Entry.Ping)
|| e.PropertyName == nameof(Entry.Pinging)
@@ -225,12 +302,27 @@ namespace Hosts.ViewModels
});
}
private void FindDuplicates()
private void FindDuplicates(CancellationToken cancellationToken)
{
foreach (var entry in _entries)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
if (!_userSettings.LoopbackDuplicates && _loopbackAddresses.Contains(entry.Address))
{
continue;
}
SetDuplicate(entry);
}
catch (OperationCanceledException)
{
Logger.LogInfo("FindDuplicates cancelled");
return;
}
}
}
private void FindDuplicates(string address, IEnumerable<string> hosts)
@@ -247,46 +339,27 @@ namespace Hosts.ViewModels
private void SetDuplicate(Entry entry)
{
if (!_userSettings.LoopbackDuplicates && _loopbackAddresses.Contains(entry.Address))
{
_dispatcherQueue.TryEnqueue(() =>
{
entry.Duplicate = false;
});
return;
}
var hosts = entry.SplittedHosts;
var duplicate = _entries.FirstOrDefault(e =>
e != entry
var duplicate = _entries.FirstOrDefault(e => e != entry
&& e.Type == entry.Type
&& (string.Equals(e.Address, entry.Address, StringComparison.InvariantCultureIgnoreCase)
|| hosts.Intersect(e.SplittedHosts, StringComparer.InvariantCultureIgnoreCase).Any())) != null;
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => entry.Duplicate = duplicate);
}
private ObservableCollection<Entry> GetFilteredEntries()
_dispatcherQueue.TryEnqueue(() =>
{
if (_entries == null)
{
return new ObservableCollection<Entry>();
}
var filter = _entries.AsEnumerable();
if (!string.IsNullOrWhiteSpace(_addressFilter))
{
filter = filter.Where(e => e.Address.Contains(_addressFilter, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(_hostsFilter))
{
filter = filter.Where(e => e.Hosts.Contains(_hostsFilter, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(_commentFilter))
{
filter = filter.Where(e => e.Comment.Contains(_commentFilter, StringComparison.OrdinalIgnoreCase));
}
if (_showOnlyDuplicates)
{
filter = filter.Where(e => e.Duplicate);
}
return new ObservableCollection<Entry>(filter);
entry.Duplicate = duplicate;
});
}
protected virtual void Dispose(bool disposing)

View File

@@ -49,7 +49,7 @@ namespace Hosts.Views
EntryDialog.Title = resourceLoader.GetString("AddNewEntryDialog_Title");
EntryDialog.PrimaryButtonText = resourceLoader.GetString("AddBtn");
EntryDialog.PrimaryButtonCommand = AddCommand;
EntryDialog.DataContext = new Entry(string.Empty, string.Empty, string.Empty, true);
EntryDialog.DataContext = new Entry(ViewModel.NextId, string.Empty, string.Empty, string.Empty, true);
await EntryDialog.ShowAsync();
}
@@ -164,7 +164,7 @@ namespace Hosts.Views
var index = ViewModel.Entries.IndexOf(entry);
if (index > 0)
{
ViewModel.Entries.Move(index, index - 1);
ViewModel.Move(index, index - 1);
}
}
}
@@ -179,7 +179,7 @@ namespace Hosts.Views
var index = ViewModel.Entries.IndexOf(entry);
if (index < ViewModel.Entries.Count - 1)
{
ViewModel.Entries.Move(index, index + 1);
ViewModel.Move(index, index + 1);
}
}
}

View File

@@ -15,12 +15,16 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool LaunchAdministrator { get; set; }
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool LoopbackDuplicates { get; set; }
public AdditionalLinesPosition AdditionalLinesPosition { get; set; }
public HostsProperties()
{
ShowStartupWarning = true;
LaunchAdministrator = true;
LoopbackDuplicates = false;
AdditionalLinesPosition = AdditionalLinesPosition.Top;
}
}

View File

@@ -2855,8 +2855,8 @@ Activate by holding the key for the character you want to add an accent to, then
<data name="Hosts_AdditionalLinesPosition_Top.Content" xml:space="preserve">
<value>Top</value>
</data>
<data name="Hosts_File_GroupSettings.Header" xml:space="preserve">
<value>File</value>
<data name="Hosts_Behavior_GroupSettings.Header" xml:space="preserve">
<value>Behavior</value>
</data>
<data name="Launch_Hosts.Content" xml:space="preserve">
<value>Launch Hosts File Editor</value>
@@ -3043,4 +3043,11 @@ Activate by holding the key for the character you want to add an accent to, then
<data name="GPO_AutoDownloadUpdatesIsDisabled.Title" xml:space="preserve">
<value>The system administrator has disabled the automatic download of updates.</value>
</data>
<data name="Hosts_Toggle_LoopbackDuplicates.Description" xml:space="preserve">
<value>127.0.0.1, ::1, ...</value>
<comment>"127.0.0.1 and ::1" are well known loopback addresses, do not loc</comment>
</data>
<data name="Hosts_Toggle_LoopbackDuplicates.Header" xml:space="preserve">
<value>Consider loopback addresses as duplicates</value>
</data>
</root>

View File

@@ -76,6 +76,19 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool LoopbackDuplicates
{
get => Settings.Properties.LoopbackDuplicates;
set
{
if (value != Settings.Properties.LoopbackDuplicates)
{
Settings.Properties.LoopbackDuplicates = value;
NotifyPropertyChanged();
}
}
}
public bool LaunchAdministrator
{
get => Settings.Properties.LaunchAdministrator;

View File

@@ -46,13 +46,20 @@
</labs:SettingsCard>
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="Hosts_File_GroupSettings" IsEnabled="{x:Bind Mode=OneWay, Path=ViewModel.IsEnabled}">
<controls:SettingsGroup x:Uid="Hosts_Behavior_GroupSettings" IsEnabled="{x:Bind Mode=OneWay, Path=ViewModel.IsEnabled}">
<labs:SettingsCard x:Uid="Hosts_AdditionalLinesPosition" HeaderIcon="{ui:FontIcon FontFamily={StaticResource SymbolThemeFontFamily}, Glyph=&#xE8A5;}">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.AdditionalLinesPosition, Mode=TwoWay}">
<ComboBoxItem x:Uid="Hosts_AdditionalLinesPosition_Top" />
<ComboBoxItem x:Uid="Hosts_AdditionalLinesPosition_Bottom" />
</ComboBox>
</labs:SettingsCard>
<labs:SettingsCard
x:Uid="Hosts_Toggle_LoopbackDuplicates"
HeaderIcon="{ui:FontIcon FontFamily={StaticResource SymbolThemeFontFamily}, Glyph=&#xEC27;}">
<ToggleSwitch
x:Uid="ToggleSwitch"
IsOn="{x:Bind Mode=TwoWay, Path=ViewModel.LoopbackDuplicates}" />
</labs:SettingsCard>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>