diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index c3775cd7eb..db2e329cba 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -2925,6 +2925,24 @@ public static string DeleteDots {
}
}
+ ///
+ /// Looks up a localized string similar to Delete entries.
+ ///
+ public static string DeleteEntries {
+ get {
+ return ResourceManager.GetString("DeleteEntries", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Delete entries ({0}).
+ ///
+ public static string DeleteEntriesX {
+ get {
+ return ResourceManager.GetString("DeleteEntriesX", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete entry.
///
@@ -2963,6 +2981,17 @@ public static string DeleteGroupMessage {
}
}
+ ///
+ /// Looks up a localized string similar to The selected entries are permanently deleted:
+ ///
+ ///{0}.
+ ///
+ public static string DeleteHostsFileEntriesMessage {
+ get {
+ return ResourceManager.GetString("DeleteHostsFileEntriesMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The selected entry is permanently deleted:
///
@@ -3632,6 +3661,24 @@ public static string EditDots {
}
}
+ ///
+ /// Looks up a localized string similar to Edit entries....
+ ///
+ public static string EditEntriesDots {
+ get {
+ return ResourceManager.GetString("EditEntriesDots", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Edit entries ({0}).
+ ///
+ public static string EditEntriesX {
+ get {
+ return ResourceManager.GetString("EditEntriesX", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Edit entry.
///
@@ -7309,6 +7356,15 @@ public static string MultipleInstances {
}
}
+ ///
+ /// Looks up a localized string similar to Multiple values.
+ ///
+ public static string MultipleValues {
+ get {
+ return ResourceManager.GetString("MultipleValues", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Multithreading.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index d53d5d3906..8b01156baf 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -4703,4 +4703,24 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri
This domain cannot be queried via RDAP. Try Whois instead.
+
+ Delete entries
+
+
+ The selected entries are permanently deleted:
+
+{0}
+
+
+ Edit entries ({0})
+
+
+ Edit entries...
+
+
+ Multiple values
+
+
+ Delete entries ({0})
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs b/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs
index 2a211c638c..2dfd690d62 100644
--- a/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs
+++ b/Source/NETworkManager.Models/HostsFileEditor/HostsFileEditor.cs
@@ -164,6 +164,16 @@ private static IEnumerable GetHostsFileEntries()
return entries;
}
+ ///
+ /// Enable multiple hosts file entries asynchronously.
+ ///
+ /// Entries to enable.
+ /// if entries were enabled successfully, otherwise an error result.
+ public static Task EnableEntriesAsync(IEnumerable entries)
+ {
+ return Task.Run(() => ModifyEntries(entries, e => new HostsFileEntry(true, e.IPAddress, e.Hostname, e.Comment)));
+ }
+
///
/// Enable a hosts file entry asynchronously.
///
@@ -171,24 +181,56 @@ private static IEnumerable GetHostsFileEntries()
/// if the entry was enabled successfully, otherwise an error result.
public static Task EnableEntryAsync(HostsFileEntry entry)
{
- return Task.Run(() => EnableEntry(entry));
+ return EnableEntriesAsync([entry]);
}
///
- /// Enable a hosts file entry.
+ /// Disable multiple hosts file entries asynchronously.
///
- /// Entry to enable.
- /// if the entry was enabled successfully, otherwise an error result.
- private static HostsFileEntryModifyResult EnableEntry(HostsFileEntry entry)
+ /// Entries to disable.
+ /// if entries were disabled successfully, otherwise an error result.
+ public static Task DisableEntriesAsync(IEnumerable entries)
{
- // Create a backup of the hosts file before making changes
- if (CreateBackup() == false)
- {
- Log.Error("EnableEntry - Failed to create backup before enabling entry.");
- return HostsFileEntryModifyResult.BackupError;
- }
+ return Task.Run(() => ModifyEntries(entries, e => new HostsFileEntry(false, e.IPAddress, e.Hostname, e.Comment)));
+ }
+
+ ///
+ /// Disable a hosts file entry asynchronously.
+ ///
+ /// Entry to disable.
+ /// if the entry was disabled successfully, otherwise an error result.
+ public static Task DisableEntryAsync(HostsFileEntry entry)
+ {
+ return DisableEntriesAsync([entry]);
+ }
+
+ ///
+ /// Edit the comment for multiple hosts file entries asynchronously.
+ ///
+ /// Entries to update.
+ /// The new comment to apply.
+ /// if entries were edited successfully, otherwise an error result.
+ public static Task EditCommentAsync(IEnumerable entries, string comment)
+ {
+ return Task.Run(() => ModifyEntries(entries, e => new HostsFileEntry(e.IsEnabled, e.IPAddress, e.Hostname, comment?.Trim())));
+ }
+
+ ///
+ /// Modifies multiple hosts file entries using a transform function.
+ /// All entries must be found in the hosts file, otherwise nothing is changed. Entries
+ /// that are already in the target state are left untouched (keeps their formatting).
+ ///
+ /// Entries to modify.
+ /// Function that returns the modified entry.
+ /// if entries were modified successfully, otherwise an error result.
+ private static HostsFileEntryModifyResult ModifyEntries(IEnumerable entries,
+ Func transform)
+ {
+ var pending = CreatePendingLookup(entries, out var pendingCount);
+
+ if (pendingCount == 0)
+ return HostsFileEntryModifyResult.Success;
- // Enable the entry in the hosts file
List hostsFileLines;
try
@@ -197,50 +239,63 @@ private static HostsFileEntryModifyResult EnableEntry(HostsFileEntry entry)
}
catch (Exception ex)
{
- Log.Error($"EnableEntry - Failed to read hosts file: {HostsFilePath}", ex);
+ Log.Error($"ModifyEntries - Failed to read hosts file: {HostsFilePath}", ex);
return HostsFileEntryModifyResult.ReadError;
}
- var entryFound = false;
+ var anyModified = false;
- for (var i = 0; i < hostsFileLines.Count; i++)
+ for (var i = 0; i < hostsFileLines.Count && pendingCount > 0; i++)
{
- if (hostsFileLines[i] != entry.Line)
+ if (!pending.TryGetValue(hostsFileLines[i], out var matches) || matches.Count == 0)
continue;
- entryFound = true;
-
- Log.Debug($"EnableEntry - Found entry: {hostsFileLines[i]}");
- hostsFileLines.RemoveAt(i);
+ var entry = matches.Dequeue();
+ pendingCount--;
- var newEntry = new HostsFileEntry(
- true,
- entry.IPAddress,
- entry.Hostname,
- entry.Comment
- );
+ var newEntry = transform(entry);
- Log.Debug($"EnableEntry - Enabling entry: {newEntry.Line}");
- hostsFileLines.Insert(i, newEntry.Line);
+ if (IsSameEntry(entry, newEntry))
+ {
+ Log.Debug($"ModifyEntries - Entry already up to date: {hostsFileLines[i]}");
+ continue;
+ }
- break;
+ Log.Debug($"ModifyEntries - Replacing entry: {hostsFileLines[i]} with {newEntry.Line}");
+ hostsFileLines[i] = newEntry.Line;
+ anyModified = true;
}
- if (!entryFound)
+ // All or nothing: Don't apply a partial change if an entry was changed or removed in the meantime
+ if (pendingCount > 0)
{
- Log.Warn($"EnableEntry - Entry not found in hosts file: {entry}");
+ foreach (var entry in pending.Values.SelectMany(matches => matches))
+ Log.Warn($"ModifyEntries - Entry not found in hosts file: {entry.Line}");
return HostsFileEntryModifyResult.NotFound;
}
+ if (!anyModified)
+ {
+ Log.Debug("ModifyEntries - No changes required.");
+ return HostsFileEntryModifyResult.Success;
+ }
+
+ // Create a backup of the hosts file before making changes
+ if (CreateBackup() == false)
+ {
+ Log.Error("ModifyEntries - Failed to create backup before modifying entries.");
+ return HostsFileEntryModifyResult.BackupError;
+ }
+
try
{
- Log.Debug($"EnableEntry - Writing changes to hosts file: {HostsFilePath}");
+ Log.Debug($"ModifyEntries - Writing changes to hosts file: {HostsFilePath}");
File.WriteAllLines(HostsFilePath, hostsFileLines);
}
catch (Exception ex)
{
- Log.Error($"EnableEntry - Failed to write changes to hosts file: {HostsFilePath}", ex);
+ Log.Error($"ModifyEntries - Failed to write changes to hosts file: {HostsFilePath}", ex);
return HostsFileEntryModifyResult.WriteError;
}
@@ -248,85 +303,46 @@ private static HostsFileEntryModifyResult EnableEntry(HostsFileEntry entry)
}
///
- /// Disable a hosts file entry asynchronously.
+ /// Checks if two entries are equal (ignoring the formatting of the line).
///
- /// Entry to disable.
- /// if the entry was enabled successfully, otherwise an error result.
- public static Task DisableEntryAsync(HostsFileEntry entry)
+ /// First entry.
+ /// Second entry.
+ /// true if the entries are equal; otherwise, false.
+ private static bool IsSameEntry(HostsFileEntry entry, HostsFileEntry other)
{
- return Task.Run(() => DisableEntry(entry));
+ return entry.IsEnabled == other.IsEnabled &&
+ entry.IPAddress == other.IPAddress &&
+ entry.Hostname == other.Hostname &&
+ (entry.Comment ?? string.Empty).Trim() == (other.Comment ?? string.Empty).Trim();
}
///
- /// Disable a hosts file entry.
+ /// Creates a lookup of the entries by their line in the hosts file. Identical lines are
+ /// queued, so each occurrence in the hosts file is matched only once.
///
- /// Entry to disable.
- /// if the entry was enabled successfully, otherwise an error result.
- private static HostsFileEntryModifyResult DisableEntry(HostsFileEntry entry)
+ /// Entries to look up.
+ /// Number of entries in the lookup.
+ /// Entries grouped by their line.
+ private static Dictionary> CreatePendingLookup(IEnumerable entries,
+ out int count)
{
- // Create a backup of the hosts file before making changes
- if (CreateBackup() == false)
- {
- Log.Error("DisableEntry - Failed to create backup before disabling entry.");
- return HostsFileEntryModifyResult.BackupError;
- }
-
- // Disable the entry in the hosts file
- List hostsFileLines;
-
- try
- {
- hostsFileLines = [.. File.ReadAllLines(HostsFilePath)];
- }
- catch (Exception ex)
- {
- Log.Error($"DisableEntry - Failed to read hosts file: {HostsFilePath}", ex);
- return HostsFileEntryModifyResult.ReadError;
- }
-
- var entryFound = false;
-
- for (var i = 0; i < hostsFileLines.Count; i++)
- {
- if (hostsFileLines[i] != entry.Line)
- continue;
-
- entryFound = true;
-
- Log.Debug($"DisableEntry - Found entry: {hostsFileLines[i]}");
- hostsFileLines.RemoveAt(i);
+ var lookup = new Dictionary>();
- var newEntry = new HostsFileEntry(
- false,
- entry.IPAddress,
- entry.Hostname,
- entry.Comment
- );
+ count = 0;
- Log.Debug($"DisableEntry - Disabling entry: {newEntry.Line}");
- hostsFileLines.Insert(i, newEntry.Line);
-
- break;
- }
-
- if (!entryFound)
+ foreach (var entry in entries ?? [])
{
- Log.Warn($"DisableEntry - Entry not found in hosts file: {entry.Line}");
- return HostsFileEntryModifyResult.NotFound;
- }
+ if (!lookup.TryGetValue(entry.Line, out var matches))
+ {
+ matches = new Queue();
+ lookup[entry.Line] = matches;
+ }
- try
- {
- Log.Debug($"DisableEntry - Writing changes to hosts file: {HostsFilePath}");
- File.WriteAllLines(HostsFilePath, hostsFileLines);
- }
- catch (Exception ex)
- {
- Log.Error($"DisableEntry - Failed to write changes to hosts file: {HostsFilePath}", ex);
- return HostsFileEntryModifyResult.WriteError;
+ matches.Enqueue(entry);
+ count++;
}
- return HostsFileEntryModifyResult.Success;
+ return lookup;
}
///
@@ -388,103 +404,45 @@ private static HostsFileEntryModifyResult AddEntry(HostsFileEntry entry)
///
/// Entry to edit.
/// New entry to replace the old one.
- /// if the entry was edited successfully, otherwise an error result."/>
+ /// if the entry was edited successfully, otherwise an error result.
public static Task EditEntryAsync(HostsFileEntry entry, HostsFileEntry newEntry)
{
- return Task.Run(() => EditEntry(entry, newEntry));
+ return Task.Run(() => ModifyEntries([entry], _ => newEntry));
}
///
- /// Edit a hosts file entry.
+ /// Delete multiple hosts file entries asynchronously.
///
- /// Entry to edit.
- /// New entry to replace the old one.
- /// if the entry was edited successfully, otherwise an error result."/>
- private static HostsFileEntryModifyResult EditEntry(HostsFileEntry entry, HostsFileEntry newEntry)
+ /// Entries to delete.
+ /// if entries were deleted successfully, otherwise an error result.
+ public static Task DeleteEntriesAsync(IEnumerable entries)
{
- // Create a backup of the hosts file before making changes
- if (CreateBackup() == false)
- {
- Log.Error("EditEntry - Failed to create backup before editing entry.");
- return HostsFileEntryModifyResult.BackupError;
- }
-
- // Replace the entry from the hosts file
- List hostsFileLines;
-
- try
- {
- hostsFileLines = [.. File.ReadAllLines(HostsFilePath)];
- }
- catch (Exception ex)
- {
- Log.Error($"EditEntry - Failed to read hosts file: {HostsFilePath}", ex);
- return HostsFileEntryModifyResult.ReadError;
- }
-
- var entryFound = false;
-
- for (var i = 0; i < hostsFileLines.Count; i++)
- {
- if (hostsFileLines[i] != entry.Line)
- continue;
-
- entryFound = true;
-
- Log.Debug($"EditEntry - Found entry: {hostsFileLines[i]}");
- hostsFileLines.RemoveAt(i);
-
- Log.Debug($"EditEntry - Editing entry: {newEntry.Line}");
- hostsFileLines.Insert(i, newEntry.Line);
-
- break;
- }
-
- if (!entryFound)
- {
- Log.Warn($"EditEntry - Entry not found in hosts file: {entry.Line}");
- return HostsFileEntryModifyResult.NotFound;
- }
-
- try
- {
- Log.Debug($"EditEntry - Writing changes to hosts file: {HostsFilePath}");
- File.WriteAllLines(HostsFilePath, hostsFileLines);
- }
- catch (Exception ex)
- {
- Log.Error($"EditEntry - Failed to write changes to hosts file: {HostsFilePath}", ex);
- return HostsFileEntryModifyResult.WriteError;
- }
-
- return HostsFileEntryModifyResult.Success;
+ return Task.Run(() => DeleteEntries(entries));
}
///
/// Delete a hosts file entry asynchronously.
///
- /// Entry to delete."/>
- /// if the entry was enabled successfully, otherwise an error result.s
+ /// Entry to delete.
+ /// if the entry was deleted successfully, otherwise an error result.
public static Task DeleteEntryAsync(HostsFileEntry entry)
{
- return Task.Run(() => DeleteEntry(entry));
+ return DeleteEntriesAsync([entry]);
}
///
- /// Delete a hosts file entry.
+ /// Delete multiple hosts file entries.
+ /// All entries must be found in the hosts file, otherwise nothing is deleted.
///
- /// Entry to delete."/>
- /// if the entry was enabled successfully, otherwise an error result.
- private static HostsFileEntryModifyResult DeleteEntry(HostsFileEntry entry)
+ /// Entries to delete.
+ /// if entries were deleted successfully, otherwise an error result.
+ private static HostsFileEntryModifyResult DeleteEntries(IEnumerable entries)
{
- // Create a backup of the hosts file before making changes
- if (CreateBackup() == false)
- {
- Log.Error("DeleteEntry - Failed to create backup before deleting entry.");
- return HostsFileEntryModifyResult.BackupError;
- }
+ var pending = CreatePendingLookup(entries, out var pendingCount);
+
+ if (pendingCount == 0)
+ return HostsFileEntryModifyResult.Success;
- // Remove the entry from the hosts file
List hostsFileLines;
try
@@ -493,39 +451,47 @@ private static HostsFileEntryModifyResult DeleteEntry(HostsFileEntry entry)
}
catch (Exception ex)
{
- Log.Error($"DeleteEntry - Failed to read hosts file: {HostsFilePath}", ex);
+ Log.Error($"DeleteEntries - Failed to read hosts file: {HostsFilePath}", ex);
return HostsFileEntryModifyResult.ReadError;
}
- var entryFound = false;
-
- for (var i = 0; i < hostsFileLines.Count; i++)
+ // Iterate backwards so removing lines doesn't shift the remaining indices
+ for (var i = hostsFileLines.Count - 1; i >= 0 && pendingCount > 0; i--)
{
- if (hostsFileLines[i] != entry.Line)
+ if (!pending.TryGetValue(hostsFileLines[i], out var matches) || matches.Count == 0)
continue;
- entryFound = true;
+ matches.Dequeue();
+ pendingCount--;
- Log.Debug($"DeleteEntry - Found entry: {hostsFileLines[i]}");
+ Log.Debug($"DeleteEntries - Found entry: {hostsFileLines[i]}");
hostsFileLines.RemoveAt(i);
-
- break;
}
- if (!entryFound)
+ // All or nothing: Don't apply a partial change if an entry was changed or removed in the meantime
+ if (pendingCount > 0)
{
- Log.Warn($"DeleteEntry - Entry not found in hosts file: {entry.Line}");
+ foreach (var entry in pending.Values.SelectMany(matches => matches))
+ Log.Warn($"DeleteEntries - Entry not found in hosts file: {entry.Line}");
+
return HostsFileEntryModifyResult.NotFound;
}
+ // Create a backup of the hosts file before making changes
+ if (CreateBackup() == false)
+ {
+ Log.Error("DeleteEntries - Failed to create backup before deleting entries.");
+ return HostsFileEntryModifyResult.BackupError;
+ }
+
try
{
- Log.Debug($"DeleteEntry - Writing changes to hosts file: {HostsFilePath}");
+ Log.Debug($"DeleteEntries - Writing changes to hosts file: {HostsFilePath}");
File.WriteAllLines(HostsFilePath, hostsFileLines);
}
catch (Exception ex)
{
- Log.Error($"DeleteEntry - Failed to write changes to hosts file: {HostsFilePath}", ex);
+ Log.Error($"DeleteEntries - Failed to write changes to hosts file: {HostsFilePath}", ex);
return HostsFileEntryModifyResult.WriteError;
}
diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorEntryViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorEntryViewModel.cs
index 766145ca8b..086584f711 100644
--- a/Source/NETworkManager/ViewModels/HostsFileEditorEntryViewModel.cs
+++ b/Source/NETworkManager/ViewModels/HostsFileEditorEntryViewModel.cs
@@ -1,15 +1,24 @@
+using NETworkManager.Localization.Resources;
using NETworkManager.Models.HostsFileEditor;
using NETworkManager.Utilities;
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Windows.Input;
namespace NETworkManager.ViewModels;
///
-/// ViewModel for adding or editing a hosts file entry in the HostsFileEditor dialog.
+/// ViewModel for adding or editing a hosts file entry (or bulk editing multiple entries)
+/// in the HostsFileEditor dialog.
///
public class HostsFileEditorEntryViewModel : ViewModelBase
{
+ ///
+ /// Indicates whether the view model is loading (initial values are being set).
+ ///
+ private readonly bool _isLoading;
+
///
/// Creates a new instance of for adding or
/// editing a hosts file entry.
@@ -20,6 +29,8 @@ public class HostsFileEditorEntryViewModel : ViewModelBase
public HostsFileEditorEntryViewModel(Action okCommand,
Action cancelHandler, HostsFileEntry entry = null)
{
+ _isLoading = true;
+
OKCommand = new RelayCommand(_ => okCommand(this));
CancelCommand = new RelayCommand(_ => cancelHandler(this));
@@ -38,6 +49,37 @@ public HostsFileEditorEntryViewModel(Action okCom
Hostname = entry.Hostname;
Comment = entry.Comment;
}
+
+ _isLoading = false;
+ }
+
+ ///
+ /// Creates a new instance of for bulk editing
+ /// multiple hosts file entries. Only the comment can be changed in this mode.
+ ///
+ /// OK command to save the entries.
+ /// Cancel command to discard the changes.
+ /// Entries to edit.
+ public HostsFileEditorEntryViewModel(Action okCommand,
+ Action cancelHandler, IReadOnlyList entries)
+ {
+ _isLoading = true;
+
+ OKCommand = new RelayCommand(_ => okCommand(this));
+ CancelCommand = new RelayCommand(_ => cancelHandler(this));
+
+ Entries = entries;
+ IsBulkEdit = true;
+
+ var comments = entries.Select(e => e.Comment ?? string.Empty).Distinct().ToList();
+
+ // Prefill the comment if all entries share the same one, otherwise show a "multiple values" hint
+ if (comments.Count == 1)
+ Comment = comments[0];
+ else
+ IsCommentMixed = true;
+
+ _isLoading = false;
}
///
@@ -55,6 +97,46 @@ public HostsFileEditorEntryViewModel(Action okCom
///
public HostsFileEntry Entry { get; } = null;
+ ///
+ /// Gets the hosts file entries (bulk edit).
+ ///
+ public IReadOnlyList Entries { get; } = [];
+
+ ///
+ /// Indicates whether multiple entries are edited at once.
+ ///
+ public bool IsBulkEdit { get; }
+
+ ///
+ /// Indicates whether the selected entries have different comments (bulk edit).
+ ///
+ public bool IsCommentMixed { get; }
+
+ ///
+ /// Indicates whether the comment was changed by the user.
+ ///
+ public bool IsCommentChanged
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(CommentWatermark));
+ }
+ }
+
+ ///
+ /// Watermark of the comment text box. Shows a "multiple values" hint as long as the
+ /// selected entries have different comments and the user did not change the comment.
+ ///
+ public string CommentWatermark => IsCommentMixed && !IsCommentChanged
+ ? Strings.MultipleValues
+ : Strings.ExampleHostsFileEntryComment;
+
///
/// Indicates whether the entry is enabled or not.
///
@@ -116,7 +198,11 @@ public string Comment
return;
field = value;
+
+ if (!_isLoading)
+ IsCommentChanged = true;
+
OnPropertyChanged();
}
}
-}
\ No newline at end of file
+}
diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
index 0b69b39d89..6dc80a090d 100644
--- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
@@ -9,6 +9,7 @@
using NETworkManager.Views;
using System;
using System.Collections;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
@@ -28,6 +29,11 @@ public class HostsFileEditorViewModel : ViewModelBase
private static readonly ILog Log = LogManager.GetLogger(typeof(HostsFileEditorViewModel));
+ ///
+ /// Maximum number of entries listed in the delete confirmation dialog.
+ ///
+ private const int DeleteEntriesMaxDisplayed = 10;
+
///
/// Indicates whether the view model is loading.
///
@@ -105,6 +111,16 @@ public IList SelectedResults
}
} = new ArrayList();
+ ///
+ /// Indicates whether at least one of the selected entries is enabled (used to show "Disable").
+ ///
+ public bool IsAnySelectedEntryEnabled => GetTargetEntries().Any(e => e.IsEnabled);
+
+ ///
+ /// Indicates whether at least one of the selected entries is disabled (used to show "Enable").
+ ///
+ public bool IsAnySelectedEntryDisabled => GetTargetEntries().Any(e => !e.IsEnabled);
+
///
/// Gets or sets a value indicating whether the view model is modifying an entry.
///
@@ -299,6 +315,20 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro
return Application.Current.MainWindow.ShowChildWindowAsync(childWindow);
}
+ ///
+ /// Helper to get the target entries for operations (either multi-selection or single selected entry).
+ ///
+ private List GetTargetEntries()
+ {
+ if (SelectedResults != null && SelectedResults.Count > 1)
+ return [.. SelectedResults.Cast()];
+
+ if (SelectedResult != null)
+ return [SelectedResult];
+
+ return [];
+ }
+
///
/// Gets the command to enable the selected entry.
///
@@ -306,13 +336,18 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro
new RelayCommand(parameter => { _ = EnableEntryAction(); }, ModifyEntry_CanExecute);
///
- /// Action to enable the selected entry.
+ /// Action to enable the selected entry or entries.
///
private async Task EnableEntryAction()
{
+ var entries = GetTargetEntries();
+
+ if (entries.Count == 0)
+ return;
+
IsModifying = true;
- var result = await HostsFileEditor.EnableEntryAsync(SelectedResult);
+ var result = await HostsFileEditor.EnableEntriesAsync(entries);
if (result != HostsFileEntryModifyResult.Success)
await ShowErrorMessageAsync(result);
@@ -327,13 +362,18 @@ private async Task EnableEntryAction()
new RelayCommand(parameter => { _ = DisableEntryAction(); }, ModifyEntry_CanExecute);
///
- /// Action to disable the selected entry.
+ /// Action to disable the selected entry or entries.
///
private async Task DisableEntryAction()
{
+ var entries = GetTargetEntries();
+
+ if (entries.Count == 0)
+ return;
+
IsModifying = true;
- var result = await HostsFileEditor.DisableEntryAsync(SelectedResult);
+ var result = await HostsFileEditor.DisableEntriesAsync(entries);
if (result != HostsFileEntryModifyResult.Success)
await ShowErrorMessageAsync(result);
@@ -392,49 +432,66 @@ private async Task AddEntryAction()
}
///
- /// Gets the command to edit the selected entry.
+ /// Gets the command to edit the selected entry or entries.
///
public ICommand EditEntryCommand =>
new RelayCommand(parameter => { _ = EditEntryAction(); }, ModifyEntry_CanExecute);
///
- /// Action to edit the selected entry.
+ /// Action to edit the selected entry or entries. Multiple entries are edited in bulk
+ /// (only the comment can be changed).
///
private async Task EditEntryAction()
{
+ var entries = GetTargetEntries();
+
+ if (entries.Count == 0)
+ return;
+
IsModifying = true;
var childWindow = new HostsFileEditorEntryChildWindow();
- var childWindowViewModel = new HostsFileEditorEntryViewModel(async instance =>
+ async void OKAction(HostsFileEditorEntryViewModel instance)
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- var result = await HostsFileEditor.EditEntryAsync(instance.Entry, new HostsFileEntry
- (
- instance.IsEnabled,
- instance.IPAddress,
- instance.Hostname,
- instance.Comment
- )
- );
+ var result = instance.IsBulkEdit
+ ? await HostsFileEditor.EditCommentAsync(instance.Entries, instance.Comment)
+ : await HostsFileEditor.EditEntryAsync(instance.Entry, new HostsFileEntry
+ (
+ instance.IsEnabled,
+ instance.IPAddress,
+ instance.Hostname,
+ instance.Comment
+ )
+ );
if (result != HostsFileEntryModifyResult.Success)
await ShowErrorMessageAsync(result);
IsModifying = false;
- }, _ =>
+ }
+
+ void CancelAction(HostsFileEditorEntryViewModel _)
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
IsModifying = false;
- }, SelectedResult);
-
- childWindow.Title = Strings.EditEntry;
+ }
- childWindow.DataContext = childWindowViewModel;
+ if (entries.Count > 1)
+ {
+ childWindow.Title = string.Format(Strings.EditEntriesX, entries.Count);
+ childWindow.DataContext = new HostsFileEditorEntryViewModel(OKAction, CancelAction, entries);
+ }
+ else
+ {
+ childWindow.Title = Strings.EditEntry;
+ childWindow.DataContext = new HostsFileEditorEntryViewModel(OKAction, CancelAction, entries[0]);
+ }
ConfigurationManager.Current.IsChildWindowOpen = true;
@@ -448,17 +505,41 @@ private async Task EditEntryAction()
new RelayCommand(parameter => { _ = DeleteEntryAction(); }, ModifyEntry_CanExecute);
///
- /// Action to delete the selected entry.
+ /// Action to delete the selected entry or entries.
///
private async Task DeleteEntryAction()
{
+ var entries = GetTargetEntries();
+
+ if (entries.Count == 0)
+ return;
+
IsModifying = true;
+ string message;
+
+ if (entries.Count == 1)
+ {
+ message = string.Format(Strings.DeleteHostsFileEntryMessage, entries[0].IPAddress, entries[0].Hostname,
+ string.IsNullOrEmpty(entries[0].Comment) ? "" : $"# {entries[0].Comment}");
+ }
+ else
+ {
+ // Show the first entries, the rest is summarized as "(+X more)"
+ var lines = entries.Take(DeleteEntriesMaxDisplayed)
+ .Select(e => $"{e.IPAddress} {e.Hostname} {(string.IsNullOrEmpty(e.Comment) ? "" : $"# {e.Comment}")}".TrimEnd())
+ .ToList();
+
+ if (entries.Count > DeleteEntriesMaxDisplayed)
+ lines.Add(string.Format(Strings.PlusXMore, entries.Count - DeleteEntriesMaxDisplayed));
+
+ message = string.Format(Strings.DeleteHostsFileEntriesMessage, string.Join(Environment.NewLine, lines));
+ }
+
var result = await DialogHelper.ShowConfirmationMessageAsync(
Application.Current.MainWindow,
- Strings.DeleteEntry,
- string.Format(Strings.DeleteHostsFileEntryMessage, SelectedResult.IPAddress, SelectedResult.Hostname,
- string.IsNullOrEmpty(SelectedResult.Comment) ? "" : $"# {SelectedResult.Comment}"),
+ entries.Count == 1 ? Strings.DeleteEntry : string.Format(Strings.DeleteEntriesX, entries.Count),
+ message,
ChildWindowIcon.Info,
Strings.Delete);
@@ -468,8 +549,7 @@ private async Task DeleteEntryAction()
return;
}
-
- var modifyResult = await HostsFileEditor.DeleteEntryAsync(SelectedResult);
+ var modifyResult = await HostsFileEditor.DeleteEntriesAsync(entries);
if (modifyResult != HostsFileEntryModifyResult.Success)
await ShowErrorMessageAsync(modifyResult);
@@ -609,6 +689,16 @@ private async Task Refresh(bool init = false)
IsRefreshing = false;
}
+ ///
+ /// Called when a context menu is opened. Updates the selection dependent properties,
+ /// because is changed in place (no change notification).
+ ///
+ public void OnContextMenuOpened()
+ {
+ OnPropertyChanged(nameof(IsAnySelectedEntryEnabled));
+ OnPropertyChanged(nameof(IsAnySelectedEntryDisabled));
+ }
+
///
/// Called when the view becomes visible.
///
diff --git a/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml b/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml
index 2bfe7c0996..a8aef6ccaf 100644
--- a/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml
+++ b/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml
@@ -18,6 +18,7 @@
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:HostsFileEditorEntryViewModel}">
+
@@ -37,21 +38,27 @@
-
-
-
+
+ Text="{x:Static localization:Strings.Enabled}"
+ Visibility="{Binding IsBulkEdit, Converter={StaticResource BooleanReverseToVisibilityCollapsedConverter}}"
+ Margin="0,0,0,10" />
-
+ IsOn="{Binding IsEnabled}"
+ Visibility="{Binding IsBulkEdit, Converter={StaticResource BooleanReverseToVisibilityCollapsedConverter}}"
+ Margin="0,0,0,10" />
+
@@ -62,10 +69,14 @@
-
+
@@ -76,11 +87,12 @@
-
+
@@ -102,6 +114,11 @@
+
+
+
+
diff --git a/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml.cs b/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml.cs
index 4d52038ece..cfedff3e04 100644
--- a/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml.cs
+++ b/Source/NETworkManager/Views/HostsFileEditorEntryChildWindow.xaml.cs
@@ -1,4 +1,5 @@
-using System;
+using NETworkManager.ViewModels;
+using System;
using System.Windows;
using System.Windows.Threading;
@@ -15,7 +16,11 @@ private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e)
{
Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate
{
- TextBoxIPAddress.Focus();
+ // Bulk edit only allows changing the comment
+ if (DataContext is HostsFileEditorEntryViewModel { IsBulkEdit: true })
+ TextBoxComment.Focus();
+ else
+ TextBoxIPAddress.Focus();
}));
}
}
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/HostsFileEditorView.xaml b/Source/NETworkManager/Views/HostsFileEditorView.xaml
index d2adbbf654..2a5bab43be 100644
--- a/Source/NETworkManager/Views/HostsFileEditorView.xaml
+++ b/Source/NETworkManager/Views/HostsFileEditorView.xaml
@@ -168,8 +168,57 @@
-