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 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/HostsFileEditorView.xaml.cs b/Source/NETworkManager/Views/HostsFileEditorView.xaml.cs index 95a14df957..5f2a2afa9d 100644 --- a/Source/NETworkManager/Views/HostsFileEditorView.xaml.cs +++ b/Source/NETworkManager/Views/HostsFileEditorView.xaml.cs @@ -28,6 +28,8 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) { if (sender is ContextMenu menu) menu.DataContext = _viewModel; + + _viewModel.OnContextMenuOpened(); } private void DataGridRow_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) diff --git a/Website/docs/application/hosts-file-editor.md b/Website/docs/application/hosts-file-editor.md index 32f9cf8b18..3e3260d3c9 100644 --- a/Website/docs/application/hosts-file-editor.md +++ b/Website/docs/application/hosts-file-editor.md @@ -44,22 +44,30 @@ The application automatically creates a daily backup of the `hosts` file, retain ### Context menu +Multiple entries can be selected (`Ctrl` / `Shift` + click) to enable, disable, edit or delete them at once. + | Action | Description | |--------|-------------| -| **Enable** | Enables the selected entry | -| **Disable** | Disables the selected entry | -| **Edit** | Opens the edit dialog for the selected entry | -| **Delete** | Removes the selected entry | -| **Copy** | Copies the entry information to the clipboard | -| **Export** | Exports the entry to a file | +| **Enable** | Enables the selected entry or entries | +| **Disable** | Disables the selected entry or entries | +| **Edit** | Opens the edit dialog for the selected entry. If multiple entries are selected, see [Edit multiple entries](#edit-multiple-entries) | +| **Delete** | Removes the selected entry or entries (after confirmation) | +| **Copy** | Copies the entry information to the clipboard (single entry only) | +| **Export** | Exports the selected entries (or all entries) to a file | ### Keyboard shortcuts | Key | Action | |-----|--------| | `F5` | Refresh | -| `F2` | Edit selected entry | -| `Del` | Delete selected entry | +| `F2` | Edit selected entry or entries | +| `Del` | Delete selected entry or entries | + +:::note + +If one of the selected entries was changed or removed in the `hosts` file in the meantime (e.g. by another program), the action is not applied to any of the selected entries and an error is shown. The list is refreshed automatically, so the action can simply be repeated. + +::: ## Add entry @@ -122,3 +130,9 @@ Optional comment associated with the entry. The comment is written after the hos **Default:** `Empty` **Example:** `Test server not reachable via DNS` + +## Edit multiple entries + +If multiple entries are selected, **Edit entries...** (or `F2`) opens the same dialog in bulk mode. Only the [comment](#comment) can be changed, it is applied to all selected entries. To enable or disable multiple entries, use **Enable** / **Disable** in the context menu. + +If all selected entries have the same comment, it is pre-filled. Otherwise the field is empty and shows `Multiple values`. **OK** is only available after the comment was changed, so the existing comments are not overwritten by accident. To remove the comment from all selected entries, clear the field (e.g. type a character and delete it again) and confirm with **OK**. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 9f869abb42..5e8723f1b1 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -78,6 +78,11 @@ Release date: **xx.xx.2026** - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) +**Hosts File Editor** + +- Multiple entries can now be selected to enable, disable or delete them at once, or to change their comment via **Edit entries...** (`F2`). The edit dialog switches to a bulk mode in which only the comment can be changed. [#3624](https://github.com/BornToBeRoot/NETworkManager/pull/3624) +- Entries that don't change (e.g. enabling an already enabled entry, or confirming the edit dialog without changes) are no longer rewritten in the `hosts` file, so custom formatting such as tabs or alignment is kept. [#3624](https://github.com/BornToBeRoot/NETworkManager/pull/3624) + ## Bug Fixes - Fixed changes to lists in the settings (e.g. query histories, port profiles, custom commands) not being saved if no other setting was changed in the same session. This happened since the settings are stored as JSON. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607)