diff --git a/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/ManagedHostsPendingChangeDiffTests.cs b/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/ManagedHostsPendingChangeDiffTests.cs new file mode 100644 index 0000000..aa6e7c0 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/ManagedHostsPendingChangeDiffTests.cs @@ -0,0 +1,90 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Tests.Models.EffectiveConfig; + +public class ManagedHostsPendingChangeDiffTests +{ + [Fact] + public void Build_same_entries_returns_empty() + { + var e = new HostEntry { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List { "h" } }; + var oldL = new List { e }; + var newL = new List + { + new() + { + Id = e.Id, + LineNumber = e.LineNumber, + Address = e.Address, + Names = new List(e.Names) + } + }; + Assert.Empty(ManagedHostsPendingChangeDiff.Build(oldL, newL)); + } + + [Fact] + public void Build_added_entry() + { + var oldL = new List(); + var newL = new List + { + new() { Id = "new:x", LineNumber = 1, Address = "192.168.0.1", Names = new List { "router" } } + }; + var d = ManagedHostsPendingChangeDiff.Build(oldL, newL); + var a = Assert.Single(d); + Assert.Equal(ManagedHostsLineDiffKind.Added, a.Kind); + Assert.Contains("192.168.0.1", a.AfterLine, StringComparison.Ordinal); + Assert.Contains("router", a.AfterLine, StringComparison.Ordinal); + } + + [Fact] + public void Build_removed_entry() + { + var oldL = new List + { + new() { Id = "1", LineNumber = 1, Address = "10.0.0.2", Names = new List { "x" } } + }; + var newL = new List(); + var d = ManagedHostsPendingChangeDiff.Build(oldL, newL); + var r = Assert.Single(d); + Assert.Equal(ManagedHostsLineDiffKind.Removed, r.Kind); + Assert.Contains("10.0.0.2", r.BeforeLine, StringComparison.Ordinal); + } + + [Fact] + public void Build_modified_address() + { + var oldL = new List + { + new() { Id = "k", LineNumber = 1, Address = "10.0.0.1", Names = new List { "h" } } + }; + var newL = new List + { + new() { Id = "k", LineNumber = 1, Address = "10.0.0.9", Names = new List { "h" } } + }; + var d = ManagedHostsPendingChangeDiff.Build(oldL, newL); + var m = Assert.Single(d); + Assert.Equal(ManagedHostsLineDiffKind.Modified, m.Kind); + Assert.Contains("10.0.0.1", m.BeforeLine, StringComparison.Ordinal); + Assert.Contains("10.0.0.9", m.AfterLine, StringComparison.Ordinal); + } + + [Fact] + public void Build_order_only() + { + var oldL = new List + { + new() { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List { "a" } }, + new() { Id = "b", LineNumber = 2, Address = "10.0.0.2", Names = new List { "b" } } + }; + var newL = new List + { + new() { Id = "b", LineNumber = 1, Address = "10.0.0.2", Names = new List { "b" } }, + new() { Id = "a", LineNumber = 2, Address = "10.0.0.1", Names = new List { "a" } } + }; + var d = ManagedHostsPendingChangeDiff.Build(oldL, newL); + var o = Assert.Single(d); + Assert.Equal(ManagedHostsLineDiffKind.OrderOnly, o.Kind); + } +} diff --git a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEntryEditValidatorTests.cs b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEntryEditValidatorTests.cs new file mode 100644 index 0000000..3f53562 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEntryEditValidatorTests.cs @@ -0,0 +1,34 @@ +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Tests.Models.Hosts; + +public class HostsEntryEditValidatorTests +{ + [Fact] + public void Empty_is_invalid() + { + var e = HostsEntryEditValidator.GetErrors("", Array.Empty()); + Assert.NotEmpty(e); + } + + [Fact] + public void Valid_ipv4_and_name_ok() + { + Assert.True(HostsEntryEditValidator.IsValid("192.168.1.1", new[] { "router" })); + } + + [Fact] + public void Missing_names_invalid() + { + var e = HostsEntryEditValidator.GetErrors("10.0.0.1", Array.Empty()); + Assert.Contains(e, x => x.Contains("hostname", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Field_errors_split_address_and_names() + { + Assert.NotEmpty(HostsEntryEditValidator.GetAddressFieldErrors("")); + Assert.Empty(HostsEntryEditValidator.GetHostnameFieldErrors(new[] { "h" })); + Assert.NotEmpty(HostsEntryEditValidator.GetHostnameFieldErrors(Array.Empty())); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs index 9209021..8be01ad 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs @@ -14,10 +14,10 @@ public class EffectiveConfigEditSessionChangedEventTests { var saveService = new StubSaveService(); var session = new EffectiveConfigEditSession(saveService); - session.EnterEditMode(); + session.Ui.EnterEditMode(); var raised = false; session.Changed += () => raised = true; - session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null)); + session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null)); Assert.True(raised); } @@ -28,7 +28,7 @@ public class EffectiveConfigEditSessionChangedEventTests var session = new EffectiveConfigEditSession(saveService); var raised = false; session.Changed += () => raised = true; - session.EnterEditMode(); + session.Ui.EnterEditMode(); Assert.True(raised); } @@ -37,27 +37,39 @@ public class EffectiveConfigEditSessionChangedEventTests { var saveService = new StubSaveService(); var session = new EffectiveConfigEditSession(saveService); - session.EnterEditMode(); - session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null)); + session.Ui.EnterEditMode(); + session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null)); var raised = false; session.Changed += () => raised = true; - session.RevertChange("resolver", "port"); + session.Draft.RevertChange("resolver", "port"); Assert.True(raised); } [Fact] - public void TrackManagedHostsChange_StoresChange_AndRevertManagedHostsChange_RemovesIt() + public void Ui_EnterEditMode_DoesNotClearDraft() { var saveService = new StubSaveService(); var session = new EffectiveConfigEditSession(saveService); - session.EnterEditMode(); + var oldEntries = new List { new() { Address = "1.2.3.4", Names = new List { "a" } } }; + var newEntries = new List { new() { Address = "1.2.3.4", Names = new List { "b" } } }; + session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/tmp/managed.hosts"); + Assert.Single(session.Draft.PendingChanges); + session.Ui.EnterEditMode(); + Assert.Single(session.Draft.PendingChanges.OfType()); + } + + [Fact] + public void SetManagedHostsDraft_StoresChange_AndRevertManagedHostsDraft_RemovesIt() + { + var saveService = new StubSaveService(); + var session = new EffectiveConfigEditSession(saveService); + session.Ui.EnterEditMode(); var oldEntries = new List { new() { Address = "1.2.3.4", Names = new List { "a" } } }; var newEntries = new List { new() { Address = "1.2.3.4", Names = new List { "a", "b" } } }; - var change = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts"); - session.TrackManagedHostsChange(change); - Assert.Single(session.PendingChanges.OfType()); - session.RevertManagedHostsChange(); - Assert.Empty(session.PendingChanges.OfType()); + session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/etc/hosts"); + Assert.Single(session.Draft.PendingChanges.OfType()); + session.Draft.RevertManagedHostsDraft(); + Assert.Empty(session.Draft.PendingChanges.OfType()); } private sealed class StubSaveService : IEffectiveConfigSaveService diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs index e8e9ac1..e8de662 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs @@ -31,7 +31,7 @@ public abstract class EffectiveConfigValueDisplayBase : ComponentBase /// Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk). protected object? GetEffectiveDisplayValue() { - var pending = Session?.PendingChanges.OfType().FirstOrDefault(c => + var pending = Session?.Draft.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); return pending != null ? pending.NewValue : Descriptor.GetValue(); diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index 70ece60..d8ec304 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -18,17 +18,17 @@ var _allSectionsExpanded = _sectionViews.Count > 0 && _sectionViews.All(v => _openPanels.Contains(v.SectionId)); } + OnSectionToggle="OnSectionToggle" IsEditMode="@Session.Ui.IsEditMode" ActiveFieldKey="@Session.Ui.ActiveFieldKey" /> - } @@ -71,9 +71,9 @@ return Task.CompletedTask; }, ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; }, - DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); }, + DeactivateFieldAsync = () => { Session.Ui.DeactivateField(); return InvokeAsync(StateHasChanged); }, CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }, - RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); } + RevertFieldAsync = (sectionId, optionName) => { Session.Draft.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); } }; } @@ -154,14 +154,14 @@ private void EnterEditMode() { - Session.EnterEditMode(); + Session.Ui.EnterEditMode(); RefreshCrossOptionAfterEnteringEditMode(); StateHasChanged(); } private async Task ExitEditModeWithConfirmAsync() { - if (Session.PendingChanges.Count > 0) + if (Session.Draft.PendingChanges.Count > 0) { var module = await JSRuntime.InvokeAsync("import", "./js/dialog.js"); try @@ -174,41 +174,43 @@ await module.DisposeAsync(); } } - Session.ExitEditModeDiscard(); + Session.Draft.DiscardAllDraft(); + Session.Ui.ExitEditMode(); StateHasChanged(); } private void DeactivateActiveFieldIfClicked() { - if (Session.IsEditMode && Session.ActiveFieldKey != null) + if (Session.Ui.IsEditMode && Session.Ui.ActiveFieldKey != null) { - Session.DeactivateField(); + Session.Ui.DeactivateField(); StateHasChanged(); } } private void ActivateField(string fieldKey) { - Session.ActivateField(fieldKey); + Session.Ui.ActivateField(fieldKey); StateHasChanged(); } private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args) { - Session.TrackCommit(args); + Session.Draft.TrackCommit(args); + Session.Ui.DeactivateField(); RunCrossOptionEvaluator(); StateHasChanged(); } private void RunCrossOptionEvaluator() { - var optionChanges = Session.PendingChanges.OfType().ToList(); + var optionChanges = Session.Draft.PendingChanges.OfType().ToList(); var issues = CrossOptionValidation.Validate(Status, optionChanges); - Session.SetCrossOptionIssues(issues); + Session.Draft.SetCrossOptionIssues(issues); } /// - /// clears cross-option issues; repopulate from disk + pending. + /// After entering edit mode, repopulate cross-option issues from disk + pending. /// private void RefreshCrossOptionAfterEnteringEditMode() { @@ -224,8 +226,8 @@ private void OpenSaveModal() { - var hasPending = Session.PendingChanges.Count > 0; - var hasValidation = Session.GetValidationSummary().Count > 0; + var hasPending = Session.Draft.PendingChanges.Count > 0; + var hasValidation = Session.Draft.GetValidationSummary().Count > 0; if (!hasPending && !hasValidation) return; _showSaveModal = true; @@ -241,9 +243,10 @@ private async Task ContinueEditingAfterSavedFailureAsync() { _showSaveModal = false; - Session.ExitEditModeDiscard(); + Session.Draft.DiscardAllDraft(); + Session.Ui.ExitEditMode(); await OnSaveCompleted.InvokeAsync(); - Session.EnterEditMode(); + Session.Ui.EnterEditMode(); RefreshCrossOptionAfterEnteringEditMode(); StateHasChanged(); } @@ -251,7 +254,8 @@ private void CloseSaveModalAndEndEdit() { _showSaveModal = false; - Session.ExitEditModeDiscard(); + Session.Draft.DiscardAllDraft(); + Session.Ui.ExitEditMode(); StateHasChanged(); } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor.css index f931d6d..9d9c746 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor.css +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor.css @@ -262,6 +262,18 @@ margin-bottom: 0; } +/* Multivalue display mode: editable entry row (no list number; align with list content) */ +::deep .ec-field-value-list li.ec-multi-list-entry-point { + list-style: none; + margin-left: -1.25rem; + margin-top: 0.15rem; +} + +::deep .ec-field-value-list li.ec-multi-list-empty-hint { + list-style: none; + margin-left: -1.25rem; +} + /* Long paths/URLs wrap; allow break anywhere so nothing is clipped */ ::deep .ec-field-value-item { display: inline; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor index 45a166a..e32ea3e 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor @@ -52,7 +52,7 @@ else return; } - var pending = Session?.PendingChanges.OfType().FirstOrDefault(c => + var pending = Session?.Draft.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); if (pending?.OldValue is ExplicitToggleState previousState) diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index 0dd9822..190756c 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -58,7 +58,7 @@ else if (Descriptor.IsMultiValue) @@ -68,7 +68,7 @@ else if (Descriptor.IsMultiValue) @* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@ } } @@ -137,7 +137,7 @@ else _fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create(this, HandleValueChanged)); _labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName); _optionHelpKey = Descriptor.OptionName; - var pending = Session?.PendingChanges.OfType().FirstOrDefault(c => + var pending = Session?.Draft.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); var descriptorValue = Descriptor.IsMultiValue @@ -305,7 +305,7 @@ else { _draftValue = null; _hasDraftValue = false; - Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName); + Session?.Draft.RevertChange(Descriptor.SectionId, Descriptor.OptionName); _exitAction = ExitAction.Revert; if (Ui != null) await Ui.DeactivateFieldAsync(); diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor index 80a4cbe..c7dae81 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor @@ -2,12 +2,17 @@ @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig - + @Value - + @code { @@ -17,6 +22,4 @@ [Parameter] public bool IsEditMode { get; set; } [Parameter] public string? ReadonlyBadgeAnchorId { get; set; } [Parameter] public string? FieldKey { get; set; } - [Parameter] public bool ReadonlyClickActivatesField { get; set; } - [Parameter] public string? ReadonlyTitleOverride { get; set; } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor index de503a4..ed85836 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor @@ -25,22 +25,64 @@ else {
- @if (Items?.Count > 0) - { -
    +
      + @if (Items?.Count > 0) + { @foreach (var (item, index) in Items!.Select((item, index) => (item, index))) { - var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : ""; -
    1. + var anchorId = ReadonlyBadgeAnchorIdBase != null + ? $"{ReadonlyBadgeAnchorIdBase}-{index}" + : ""; +
    2. + +
    3. } -
    - } - else - { - (none) - - } + } + @if (ShowEditableBadge && (Items?.Count ?? 0) > 0) + { +
  1. + +
  2. + } + else if ((Items?.Count ?? 0) == 0) + { +
  3. + @if (ShowEditableBadge) + { + (none) + + } + else + { + (none) + } +
  4. + } +
} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index d437869..6874e57 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -69,8 +69,8 @@ private async Task HandleSave() { - if (Session.PendingChanges.Count == 0) return; - if (Session.HasBlockingValidationErrors()) + if (Session.Draft.PendingChanges.Count == 0) return; + if (Session.Draft.HasBlockingValidationErrors()) { NotificationService.ShowError("Fix validation errors before saving."); return; @@ -81,14 +81,16 @@ _restoreResult = null; await InvokeAsync(StateHasChanged); - var result = await Session.ApplyAsync(); + var result = await Session.Draft.ApplyAsync(); _saveResult = result; if (result.Saved) { - // Changes were written to disk, so clear them from session state. - // Whether to stay in edit mode depends on whether restart succeeded. - Session.AcceptAppliedChanges(stayInEditMode: result.IsValidateFailed || !result.Restarted); + Session.Draft.AcceptAppliedChanges(); + if (!result.IsValidateFailed && result.Restarted) + Session.Ui.ExitEditMode(); + else + Session.Ui.EnterEditMode(); } if (result.Saved && result.Restarted) @@ -127,8 +129,8 @@ _restoreResult = result; if (result.Restored) { - // Restore wrote to disk, so clear any pending changes from session. - Session.AcceptAppliedChanges(stayInEditMode: false); + Session.Draft.AcceptAppliedChanges(); + Session.Ui.ExitEditMode(); } if (result.Restored && result.Restarted) diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor index 2dc70ec..2a263d3 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor @@ -228,13 +228,39 @@ } else if (c is PendingManagedHostsChange hostChange) { + var hostLineDiffs = ManagedHostsPendingChangeDiff.Build(hostChange);
-
+
- @hostChange.OldEntries.Count entries → @hostChange.NewEntries.Count entries + @hostChange.OldEntries.Count → @hostChange.NewEntries.Count entries
@System.IO.Path.GetFileName(hostChange.ManagedHostsFilePath)
+ @if (hostLineDiffs.Count > 0) + { +
+ @foreach (var d in hostLineDiffs) + { + @if (d.Kind == ManagedHostsLineDiffKind.Added) + { +
+ @d.AfterLine
+ } + else if (d.Kind == ManagedHostsLineDiffKind.Removed) + { +
− @d.BeforeLine
+ } + else if (d.Kind == ManagedHostsLineDiffKind.Modified) + { +
~ @d.BeforeLine
+
→ @d.AfterLine
+ } + else if (d.Kind == ManagedHostsLineDiffKind.OrderOnly) + { +
Line order changed (same entries).
+ } + } +
+ }
- @if (_unifiedRows == null || _unifiedRows.Count == 0) + @if (VisibleGroups.Count == 0) {

No hosts entries found.

} - else if (VisibleGroups.Count == 0) - { -

No rows match the current search.

- } else { + @if (TotalVisibleDataRows == 0 && !string.IsNullOrWhiteSpace(_search)) + { +

No rows match the current search.

+ }
@@ -90,7 +92,17 @@ else - + @foreach (var group in VisibleGroups) @@ -101,58 +113,103 @@ else
@group.Title - @if (group.GroupContainsEditableRows) - { - Editable - } @if (!group.IsActive) { Inactive } +
+
@if (!string.IsNullOrWhiteSpace(group.Subtitle)) { @group.Subtitle } + @if (group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable) + { + + } + else if (group.IsSourceEditable) + { + + } + else + { + + }
- @foreach (var row in group.Rows) + @if (group.Rows.Count == 0) { - @if (IsEditingRow(row)) + + + + } + else + { + @foreach (var row in group.Rows) { - - - - - - } - else - { - - - - - + @if (IsEditingRow(row)) + { + + + + + + } + else + { + + + + + + } } } @@ -165,6 +222,36 @@ else } + @if (_showInvalidHostEditModal) + { + + + } +
@if (!pageState.SystemHostsActive && !string.IsNullOrEmpty(pageState.SystemHostsInactiveReason)) { @@ -185,11 +272,14 @@ else
@code { + private const string EffectiveNamesHelpText = + "Effective names show how dnsmasq resolves this row from your Address and Names columns. If expand-hosts and a domain are enabled, short hostnames get that suffix. This preview updates as you type."; + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; private IReadOnlyList _unifiedRows = Array.Empty(); - private List _managedEntries = new(); - private List _originalManagedEntries = new(); + private List _restUnifiedRows = new(); + private List _baselineManagedEntries = new(); private string? _error; private string _search = ""; @@ -198,31 +288,100 @@ else private string _editAddress = ""; private string _editNames = ""; private ElementReference _editingRowRef; + private ElementReference _editAddressInputRef; private IJSObjectReference? _hostsTableScrollCueModule; + private IJSObjectReference? _hostsUiModule; + private bool _showInvalidHostEditModal; + private bool _refocusEditAfterRender; + /// When false, invalid state is hidden until the first blur, Escape, or Enter. + private bool _showHostFieldValidation; + + private IReadOnlyList CurrentManagedEntries => + Session.Draft.ManagedHostsDraft?.NewEntries ?? _baselineManagedEntries; - // Memoize: markup reads VisibleGroups.Count then foreach — avoid building twice per render. private IReadOnlyList? _visibleGroupsCache; private object? _visibleGroupsCacheRowsRef; private string _visibleGroupsCacheSearch = "\0"; + private string _visibleGroupsCacheManagedPath = "\0"; private IReadOnlyList VisibleGroups { get { + var managedPath = _status?.ManagedHostsFilePath ?? ""; if (_visibleGroupsCache != null && ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows) - && _visibleGroupsCacheSearch == _search) + && _visibleGroupsCacheSearch == _search + && _visibleGroupsCacheManagedPath == managedPath) { return _visibleGroupsCache; } - _visibleGroupsCache = HostsProjection.BuildGroups(_unifiedRows, BuildQuery()); + var built = HostsProjection.BuildGroups(_unifiedRows, BuildQuery()); + _visibleGroupsCache = EnsureManagedHostsHeaderGroup(built); _visibleGroupsCacheRowsRef = _unifiedRows; _visibleGroupsCacheSearch = _search; + _visibleGroupsCacheManagedPath = managedPath; return _visibleGroupsCache; } } + private int TotalVisibleDataRows => VisibleGroups.Sum(g => g.Rows.Count); + + private bool IsInlineEditing => !string.IsNullOrEmpty(_editingRowId); + + private bool _editingRowIsNew => + !string.IsNullOrEmpty(_editingRowId) && _editingRowId.StartsWith("new:", StringComparison.Ordinal); + + private IReadOnlyList CurrentEditValidationErrors => + string.IsNullOrEmpty(_editingRowId) + ? Array.Empty() + : HostsEntryEditValidator.GetErrors(_editAddress.Trim(), ParseNamesList(_editNames)); + + private bool IsCurrentEditValid => CurrentEditValidationErrors.Count == 0; + + private IReadOnlyList CurrentAddressFieldErrors => + string.IsNullOrEmpty(_editingRowId) + ? Array.Empty() + : HostsEntryEditValidator.GetAddressFieldErrors(_editAddress.Trim()); + + private IReadOnlyList CurrentHostnameFieldErrors => + string.IsNullOrEmpty(_editingRowId) + ? Array.Empty() + : HostsEntryEditValidator.GetHostnameFieldErrors(ParseNamesList(_editNames)); + + private string? HostEditAddressTooltip => + !_showHostFieldValidation ? null : JoinHostFieldTooltip(CurrentAddressFieldErrors); + + private string? HostEditNamesTooltip => + !_showHostFieldValidation ? null : JoinHostFieldTooltip(CurrentHostnameFieldErrors); + + private static string? JoinHostFieldTooltip(IReadOnlyList errors) => + errors.Count == 0 ? null : string.Join(" · ", errors); + + private IReadOnlyList EnsureManagedHostsHeaderGroup(IReadOnlyList groups) + { + if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) + return groups; + var path = _status.ManagedHostsFilePath; + if (groups.Any(g => g.SourceKind == HostsRowSourceKind.Managed)) + return groups; + + var placeholder = new HostsPageGroup( + Key: $"{HostsRowSourceKind.Managed}:{path}", + Title: "Managed hosts", + Subtitle: path, + SourceKind: HostsRowSourceKind.Managed, + GroupContainsEditableRows: true, + IsActive: true, + InactiveReason: null, + VisibleRowCount: 0, + Rows: Array.Empty(), + IsSourceEditable: true); + + return new[] { placeholder }.Concat(groups).ToList(); + } + private HostsPageQueryState BuildQuery() { return new HostsPageQueryState( @@ -235,6 +394,24 @@ else ActiveFilter: null); } + private void InvalidateVisibleGroupsCache() + { + _visibleGroupsCache = null; + } + + private async Task OnEffectiveNamesHelpClickAsync() + { + try + { + _hostsUiModule ??= await JSRuntime.InvokeAsync("import", "/js/modules/hosts-ui.js"); + if (await _hostsUiModule.InvokeAsync("preferEffectiveNamesHelpToast")) + NotificationService.ShowInfo(EffectiveNamesHelpText); + } + catch (JSDisconnectedException) + { + } + } + private static string RowClass(HostsPageRow row) { var parts = new List { "hosts-row" }; @@ -245,21 +422,87 @@ else return string.Join(" ", parts); } - private void OnRowDoubleClick(HostsPageRow row) => BeginEdit(row); + private string RowTitle(HostsPageRow row) => + row.IsEditable ? "Double-click to edit" : string.Empty; + + private string EmptyGroupMessage => + string.IsNullOrWhiteSpace(_search) ? "No entries." : "No matching entries."; private bool IsEditingRow(HostsPageRow row) => !string.IsNullOrEmpty(_editingRowId) && string.Equals(_editingRowId, row.Id, StringComparison.Ordinal); - private void BeginEdit(HostsPageRow row) + private async Task OnRowDoubleClickAsync(HostsPageRow row) { if (!row.IsEditable || _status == null) return; + + if (!string.IsNullOrEmpty(_editingRowId)) + { + if (string.Equals(_editingRowId, row.Id, StringComparison.Ordinal)) + return; + + if (!IsCurrentEditValid) + { + RevealHostFieldValidation(); + _showInvalidHostEditModal = true; + await InvokeAsync(StateHasChanged); + return; + } + + await CommitAndEndInlineEditAsync(); + } + + if (!Session.Ui.IsEditMode) + Session.Ui.EnterEditMode(); + var entry = FindManagedEntry(row); if (entry == null) return; + _showHostFieldValidation = false; _editingRowId = row.Id; _editAddress = entry.Address; _editNames = string.Join(", ", entry.Names); + await InvokeAsync(StateHasChanged); + } + + private void AddManagedHostFromBadge() + { + if (IsInlineEditing) + return; + AddManagedHost(); + } + + private void AddManagedHost() + { + if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing) + return; + if (!Session.Ui.IsEditMode) + Session.Ui.EnterEditMode(); + + var list = CloneEntries(CurrentManagedEntries).ToList(); + var nextLine = list.Count == 0 ? 1 : list.Max(e => e.LineNumber) + 1; + var id = "new:" + Guid.NewGuid().ToString("N"); + list.Add(new HostEntry + { + LineNumber = nextLine, + Id = id, + Address = "", + Names = new List(), + IsComment = false, + IsPassthrough = false + }); + + Session.Draft.SetManagedHostsDraft( + CloneEntries(_baselineManagedEntries), + list, + _status.ManagedHostsFilePath); + RebuildManagedOverlay(); + InvalidateVisibleGroupsCache(); + _showHostFieldValidation = false; + _editingRowId = id; + _editAddress = ""; + _editNames = ""; + StateHasChanged(); } private void CancelEdit() @@ -267,14 +510,90 @@ else _editingRowId = null; _editAddress = ""; _editNames = ""; + _showHostFieldValidation = false; + } + + private void RevealHostFieldValidation() => _showHostFieldValidation = true; + + private void OnInvalidEditContinue() + { + _showInvalidHostEditModal = false; + _refocusEditAfterRender = true; + StateHasChanged(); + } + + private async Task OnInvalidEditDiscardAsync() + { + if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) + { + _showInvalidHostEditModal = false; + CancelEdit(); + await InvokeAsync(StateHasChanged); + return; + } + + var list = CloneEntries(CurrentManagedEntries).ToList(); + var entry = list.FirstOrDefault(MatchesEditingId); + if (entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal)) + { + list.Remove(entry); + Session.Draft.SetManagedHostsDraft( + CloneEntries(_baselineManagedEntries), + list, + _status.ManagedHostsFilePath); + RebuildManagedOverlay(); + InvalidateVisibleGroupsCache(); + } + + _showInvalidHostEditModal = false; + CancelEdit(); + await InvokeAsync(StateHasChanged); + } + + private async Task OnEditFieldKeyDown(KeyboardEventArgs e) + { + if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId)) + return; + + if (e.Key == "Enter") + { + await TryFinishHostEditFromKeyboardAsync(); + return; + } + + if (e.Key != "Escape") + return; + + if (IsCurrentEditValid) + await CommitAndEndInlineEditAsync(); + else + { + RevealHostFieldValidation(); + _showInvalidHostEditModal = true; + await InvokeAsync(StateHasChanged); + } + } + + private async Task TryFinishHostEditFromKeyboardAsync() + { + if (string.IsNullOrEmpty(_editingRowId)) + return; + if (IsCurrentEditValid) + await CommitAndEndInlineEditAsync(); + else + { + RevealHostFieldValidation(); + _showInvalidHostEditModal = true; + await InvokeAsync(StateHasChanged); + } } private async Task OnEditingRowFocusOut(FocusEventArgs _) { - if (string.IsNullOrEmpty(_editingRowId)) + if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId)) return; await Task.Delay(10); - if (string.IsNullOrEmpty(_editingRowId)) + if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId)) return; var outside = true; @@ -289,24 +608,43 @@ else } if (outside) - await CommitAndEndInlineEditAsync(); + { + if (IsCurrentEditValid) + await CommitAndEndInlineEditAsync(); + else + { + RevealHostFieldValidation(); + _showInvalidHostEditModal = true; + await InvokeAsync(StateHasChanged); + } + } } private async Task CommitAndEndInlineEditAsync() { - if (string.IsNullOrEmpty(_editingRowId) || _status == null) + if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) return; - var entry = _managedEntries.FirstOrDefault(MatchesEditingId); + var list = CloneEntries(CurrentManagedEntries).ToList(); + var entry = list.FirstOrDefault(MatchesEditingId); if (entry == null) { CancelEdit(); return; } - entry.Address = _editAddress.Trim(); - entry.Names = ParseNamesList(_editNames); - ApplyManagedOverlay(); - SyncManagedHostsDraft(); + var trimmedAddr = _editAddress.Trim(); + var newNames = ParseNamesList(_editNames); + if (!HostsEntryEditValidator.IsValid(trimmedAddr, newNames)) + return; + + entry.Address = trimmedAddr; + entry.Names = newNames; + Session.Draft.SetManagedHostsDraft( + CloneEntries(_baselineManagedEntries), + list, + _status.ManagedHostsFilePath!); + RebuildManagedOverlay(); + InvalidateVisibleGroupsCache(); CancelEdit(); await InvokeAsync(StateHasChanged); } @@ -318,7 +656,7 @@ else } private HostEntry? FindManagedEntry(HostsPageRow row) => - _managedEntries.FirstOrDefault(e => + CurrentManagedEntries.FirstOrDefault(e => string.Equals(e.Id, row.Id, StringComparison.Ordinal) || string.Equals($"managed:{e.LineNumber}", row.Id, StringComparison.Ordinal)); @@ -339,19 +677,6 @@ else IsPassthrough = e.IsPassthrough }).ToList(); - private void SyncManagedHostsDraft() - { - if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) - return; - var draft = CloneEntries(_managedEntries); - var original = CloneEntries(_originalManagedEntries); - var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath); - if (pending.HasChanges) - Session.TrackManagedHostsChange(pending); - else - Session.RevertManagedHostsChange(); - } - private MarkupString RenderAddnHostsList() { if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0) @@ -372,9 +697,20 @@ else protected override async Task OnInitializedAsync() { + Session.Changed += OnSessionChanged; await Load(); } + private void OnSessionChanged() + { + if (_status != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath)) + { + RebuildManagedOverlay(); + InvalidateVisibleGroupsCache(); + } + _ = InvokeAsync(StateHasChanged); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) @@ -394,6 +730,21 @@ else { } } + + if (_refocusEditAfterRender) + { + _refocusEditAfterRender = false; + if (!string.IsNullOrEmpty(_editingRowId)) + { + try + { + await _editAddressInputRef.FocusAsync(); + } + catch (JSDisconnectedException) + { + } + } + } } private async Task RefreshStatusAsync() @@ -406,14 +757,28 @@ else catch { /* ignore */ } } + private void ReconcileManagedHostsDraftAfterLoad() + { + if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) + return; + var draft = Session.Draft.ManagedHostsDraft; + if (draft == null) + return; + if (!string.Equals(draft.ManagedHostsFilePath, _status.ManagedHostsFilePath, StringComparison.Ordinal)) + return; + Session.Draft.SetManagedHostsDraft( + CloneEntries(_baselineManagedEntries), + CloneEntries(draft.NewEntries), + _status.ManagedHostsFilePath); + } + private async Task Load() { _error = null; try { _status = await StatusClient.GetStatusAsync(); - _managedEntries = (await HostsClient.GetHostsAsync()).ToList(); - _originalManagedEntries = CloneEntries(_managedEntries); + _baselineManagedEntries = (await HostsClient.GetHostsAsync()).ToList(); if (_status?.EffectiveConfig != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath)) { @@ -421,15 +786,17 @@ else var domain = _status.EffectiveConfig.DomainValues?.FirstOrDefault(); var noHosts = _status.NoHosts; var fromApi = await HostsClient.GetUnifiedRowsAsync(expandHosts, domain, noHosts, _status.ManagedHostsFilePath); - _unifiedRows = fromApi.ToList(); - ApplyManagedOverlay(); + _restUnifiedRows = fromApi.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList(); + ReconcileManagedHostsDraftAfterLoad(); + RebuildManagedOverlay(); } else { + _restUnifiedRows = new List(); _unifiedRows = Array.Empty(); } - SyncManagedHostsDraft(); + InvalidateVisibleGroupsCache(); } catch (Exception ex) { @@ -437,7 +804,7 @@ else } } - private void ApplyManagedOverlay() + private void RebuildManagedOverlay() { if (_status?.EffectiveConfig == null || string.IsNullOrEmpty(_status.ManagedHostsFilePath)) return; @@ -447,7 +814,7 @@ else var path = _status.ManagedHostsFilePath; var managedRows = new List(); - foreach (var e in _managedEntries.Where(x => !x.IsPassthrough)) + foreach (var e in CurrentManagedEntries.Where(x => !x.IsPassthrough)) { var names = e.Names ?? (IReadOnlyList)Array.Empty(); var effective = HostsEffectiveNames.Expand(names, expandHosts, domain); @@ -466,13 +833,14 @@ else LineNumber: e.LineNumber)); } - var rest = _unifiedRows.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList(); - _unifiedRows = managedRows.Concat(rest).ToList(); + _unifiedRows = managedRows.Concat(_restUnifiedRows).ToList(); } public void Dispose() { + Session.Changed -= OnSessionChanged; _ = CleanupHostsTableScrollCueAsync(); + _ = CleanupHostsUiModuleAsync(); } private async Task CleanupHostsTableScrollCueAsync() @@ -491,4 +859,20 @@ else _hostsTableScrollCueModule = null; } + + private async Task CleanupHostsUiModuleAsync() + { + if (_hostsUiModule == null) + return; + + try + { + await _hostsUiModule.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + + _hostsUiModule = null; + } } diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css index 232666b..4abd2f2 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css @@ -101,6 +101,21 @@ } /* Group header row (colspan, not rowspan) */ +::deep .hosts-table .hosts-effective-names-th { + white-space: nowrap; +} + +::deep .hosts-table .hosts-effective-names-help { + vertical-align: middle; + text-decoration: none; + line-height: 1; +} + +::deep .hosts-table .hosts-effective-names-help:hover, +::deep .hosts-table .hosts-effective-names-help:focus-visible { + color: var(--bs-primary) !important; +} + ::deep .hosts-table .hosts-group-header-cell { background: linear-gradient(180deg, #f8fafc 0%, #f6f8fb 100%); border-top: 1px solid #dbe3ec; @@ -112,6 +127,10 @@ } .hosts-group-header__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; width: 100%; } @@ -121,13 +140,26 @@ align-items: center; gap: 0.5rem; color: #0f172a; + min-width: 0; +} + +.hosts-group-header__path { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + min-width: 0; + width: 100%; + font-weight: 500; +} + +.hosts-group-header__badge { + flex-shrink: 0; } .hosts-group-header__subtitle { - display: inline-flex; - align-items: center; + display: inline-block; line-height: 1.2; - margin-left: 0.25rem; font-weight: 500; white-space: nowrap; overflow: hidden; @@ -147,6 +179,11 @@ background-color: rgba(13, 110, 253, 0.06); } +::deep .hosts-table .hosts-row--empty td.hosts-empty-group-message { + background: #fafbfc; + font-style: italic; +} + ::deep .hosts-row code { font-size: 0.93em; color: #0f172a; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs index 1e6d46c..806b6e4 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs @@ -63,6 +63,9 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService ? first.InactiveReason : null; + var isSourceEditable = first.SourceKind == HostsRowSourceKind.Managed + || sortedRows.Any(r => r.IsEditable); + result.Add(new HostsPageGroup( Key: $"{first.SourceKind}:{first.SourcePath}", Title: title, @@ -72,7 +75,8 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService IsActive: sortedRows.Any(r => r.IsActive), InactiveReason: inactiveReason, VisibleRowCount: sortedRows.Count, - Rows: sortedRows)); + Rows: sortedRows, + IsSourceEditable: isSourceEditable)); } return result; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigDraft.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigDraft.cs new file mode 100644 index 0000000..a2f9ce4 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigDraft.cs @@ -0,0 +1,37 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// +/// Pending effective-config changes, validation issues, and apply/save. Independent of edit-mode UI state. +/// +public interface IEffectiveConfigDraft +{ + event Action? Changed; + + IReadOnlyList PendingChanges { get; } + IReadOnlyDictionary> FieldIssues { get; } + PendingManagedHostsChange? ManagedHostsDraft { get; } + + void TrackCommit(EffectiveConfigEditCommittedArgs args); + void RevertChange(string sectionId, string optionName); + + void SetManagedHostsDraft( + IReadOnlyList baseline, + IReadOnlyList draft, + string managedHostsFilePath); + + void RevertManagedHostsDraft(); + + void SetFieldIssues(string fieldKey, IReadOnlyList issues); + void ClearFieldIssues(string fieldKey); + void SetCrossOptionIssues(IReadOnlyList issues); + + bool HasBlockingValidationErrors(); + IReadOnlyList GetValidationSummary(); + + Task ApplyAsync(CancellationToken ct = default); + void AcceptAppliedChanges(); + void DiscardAllDraft(); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs index cd8a890..e273869 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs @@ -1,51 +1,15 @@ using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; /// -/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, validation, and apply. -/// Components read state from here and call methods; the owning section re-renders after state changes. +/// Scoped root for effective-config editing: holds pending changes and validation; +/// holds edit mode and active field. Subscribe to for any update. /// public interface IEffectiveConfigEditSession : IApplicationScopedService { - /// Raised when session state changes so UI (section and external widgets) can refresh. event Action? Changed; - bool IsEditMode { get; } - string? ActiveFieldKey { get; } - IReadOnlyList PendingChanges { get; } - - /// Per-field validation issues (errors block save; warnings can be confirmed). - IReadOnlyDictionary> FieldIssues { get; } - - void EnterEditMode(); - void ExitEditModeDiscard(); - void ActivateField(string fieldKey); - void DeactivateField(); - - void TrackCommit(EffectiveConfigEditCommittedArgs args); - void RevertChange(string sectionId, string optionName); - void TrackManagedHostsChange(PendingManagedHostsChange change); - void RevertManagedHostsChange(); - - void SetFieldIssues(string fieldKey, IReadOnlyList issues); - void ClearFieldIssues(string fieldKey); - - /// Replaces all cross-option validation issues (e.g. no-resolv vs server). Merged with per-field issues for display and save guard. - void SetCrossOptionIssues(IReadOnlyList issues); - - /// True if any field has one or more issues with . - bool HasBlockingValidationErrors(); - /// All issues across fields for summary display (e.g. toolbar count or save guard message). - IReadOnlyList GetValidationSummary(); - - Task ApplyAsync(CancellationToken ct = default); - - /// - /// Accepts that changes were applied to disk. Clears pending changes and optionally exits edit mode. - /// Call this after a successful write (even if restart failed), so the session state matches disk state. - /// - /// If true, keep edit mode active; if false, exit edit mode. - void AcceptAppliedChanges(bool stayInEditMode); + IEffectiveConfigDraft Draft { get; } + IEffectiveConfigUiState Ui { get; } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigUiState.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigUiState.cs new file mode 100644 index 0000000..46d714b --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigUiState.cs @@ -0,0 +1,18 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// +/// Edit affordance state only: whether the effective-config UI is in edit mode and which field is active. +/// Does not own pending changes or validation. +/// +public interface IEffectiveConfigUiState +{ + event Action? Changed; + + bool IsEditMode { get; } + string? ActiveFieldKey { get; } + + void EnterEditMode(); + void ExitEditMode(); + void ActivateField(string fieldKey); + void DeactivateField(); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigDraft.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigDraft.cs new file mode 100644 index 0000000..c2f06d5 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigDraft.cs @@ -0,0 +1,149 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +public sealed class EffectiveConfigDraft : IEffectiveConfigDraft +{ + private readonly IEffectiveConfigSaveService _saveService; + private readonly List _pending = new(); + private readonly Dictionary> _fieldIssues = new(StringComparer.OrdinalIgnoreCase); + private readonly List _crossOptionIssues = new(); + + public EffectiveConfigDraft(IEffectiveConfigSaveService saveService) + { + _saveService = saveService; + } + + public event Action? Changed; + + private void NotifyChanged() => Changed?.Invoke(); + + public IReadOnlyList PendingChanges => _pending; + + public PendingManagedHostsChange? ManagedHostsDraft => + _pending.OfType().FirstOrDefault(); + + public IReadOnlyDictionary> FieldIssues + { + get + { + var copy = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kv in _fieldIssues) + copy[kv.Key] = new List(kv.Value); + foreach (var issue in _crossOptionIssues) + { + if (!copy.TryGetValue(issue.FieldKey, out var list)) + copy[issue.FieldKey] = list = new List(); + list.Add(issue); + } + return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value, StringComparer.OrdinalIgnoreCase); + } + } + + public void DiscardAllDraft() + { + _pending.Clear(); + _fieldIssues.Clear(); + _crossOptionIssues.Clear(); + NotifyChanged(); + } + + public void SetFieldIssues(string fieldKey, IReadOnlyList issues) + { + if (string.IsNullOrEmpty(fieldKey)) return; + _fieldIssues[fieldKey] = issues.ToList(); + NotifyChanged(); + } + + public void ClearFieldIssues(string fieldKey) + { + if (string.IsNullOrEmpty(fieldKey)) return; + _fieldIssues.Remove(fieldKey); + NotifyChanged(); + } + + public void SetCrossOptionIssues(IReadOnlyList issues) + { + _crossOptionIssues.Clear(); + if (issues != null) + _crossOptionIssues.AddRange(issues); + NotifyChanged(); + } + + public bool HasBlockingValidationErrors() + { + if (_fieldIssues.Values.Any(list => list.Any(i => i.Severity == FieldIssueSeverity.Error))) + return true; + return _crossOptionIssues.Any(i => i.Severity == FieldIssueSeverity.Error); + } + + public IReadOnlyList GetValidationSummary() + { + var list = new List(_fieldIssues.Values.SelectMany(x => x)); + list.AddRange(_crossOptionIssues); + return list; + } + + public void TrackCommit(EffectiveConfigEditCommittedArgs args) + { + _pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == args.SectionId && o.OptionName == args.OptionName); + if (!ValuesEqual(args.OldValue, args.NewValue)) + _pending.Add(new PendingOptionChange( + args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath)); + NotifyChanged(); + } + + public void RevertChange(string sectionId, string optionName) + { + _pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == sectionId && o.OptionName == optionName); + ClearFieldIssues($"{sectionId}:{optionName}"); + NotifyChanged(); + } + + public void SetManagedHostsDraft( + IReadOnlyList baseline, + IReadOnlyList draft, + string managedHostsFilePath) + { + _pending.RemoveAll(c => c is PendingManagedHostsChange); + var pending = new PendingManagedHostsChange(baseline, draft, managedHostsFilePath); + if (pending.HasChanges) + _pending.Add(pending); + NotifyChanged(); + } + + public void RevertManagedHostsDraft() + { + _pending.RemoveAll(c => c is PendingManagedHostsChange); + NotifyChanged(); + } + + public async Task ApplyAsync(CancellationToken ct = default) + { + if (_pending.Count == 0) + return EffectiveConfigSaveResult.NoChanges(); + + return await _saveService.SaveAsync(_pending.ToList(), ct); + } + + public void AcceptAppliedChanges() + { + _pending.Clear(); + _fieldIssues.Clear(); + _crossOptionIssues.Clear(); + NotifyChanged(); + } + + private static IReadOnlyList? AsStringList(object? value) => value as IReadOnlyList; + + private static bool ValuesEqual(object? oldValue, object? newValue) + { + var oldList = AsStringList(oldValue); + var newList = AsStringList(newValue); + if (oldList != null || newList != null) + return (oldList ?? Array.Empty()).SequenceEqual(newList ?? Array.Empty(), StringComparer.Ordinal); + return Equals(oldValue, newValue); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs index dd1ab4a..c20a6f0 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs @@ -1,172 +1,24 @@ using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession { - private readonly IEffectiveConfigSaveService _saveService; - private readonly List _pending = new(); - private readonly Dictionary> _fieldIssues = new(StringComparer.OrdinalIgnoreCase); - private readonly List _crossOptionIssues = new(); + private readonly EffectiveConfigDraft _draft; + private readonly EffectiveConfigUiState _ui; public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService) { - _saveService = saveService; + _draft = new EffectiveConfigDraft(saveService); + _ui = new EffectiveConfigUiState(); + _draft.Changed += OnChildChanged; + _ui.Changed += OnChildChanged; } public event Action? Changed; - private void NotifyChanged() => Changed?.Invoke(); + public IEffectiveConfigDraft Draft => _draft; + public IEffectiveConfigUiState Ui => _ui; - public bool IsEditMode { get; private set; } - public string? ActiveFieldKey { get; private set; } - public IReadOnlyList PendingChanges => _pending; - public IReadOnlyDictionary> FieldIssues - { - get - { - var copy = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var kv in _fieldIssues) - copy[kv.Key] = new List(kv.Value); - foreach (var issue in _crossOptionIssues) - { - if (!copy.TryGetValue(issue.FieldKey, out var list)) - copy[issue.FieldKey] = list = new List(); - list.Add(issue); - } - return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value, StringComparer.OrdinalIgnoreCase); - } - } - - public void EnterEditMode() - { - IsEditMode = true; - ActiveFieldKey = null; - _pending.Clear(); - _fieldIssues.Clear(); - _crossOptionIssues.Clear(); - NotifyChanged(); - } - - public void ExitEditModeDiscard() - { - _pending.Clear(); - _fieldIssues.Clear(); - _crossOptionIssues.Clear(); - ActiveFieldKey = null; - IsEditMode = false; - NotifyChanged(); - } - - public void ActivateField(string fieldKey) - { - IsEditMode = true; - ActiveFieldKey = fieldKey; - NotifyChanged(); - } - - public void DeactivateField() - { - ActiveFieldKey = null; - NotifyChanged(); - } - - public void SetFieldIssues(string fieldKey, IReadOnlyList issues) - { - if (string.IsNullOrEmpty(fieldKey)) return; - _fieldIssues[fieldKey] = issues.ToList(); - NotifyChanged(); - } - - public void ClearFieldIssues(string fieldKey) - { - if (string.IsNullOrEmpty(fieldKey)) return; - _fieldIssues.Remove(fieldKey); - NotifyChanged(); - } - - public void SetCrossOptionIssues(IReadOnlyList issues) - { - _crossOptionIssues.Clear(); - if (issues != null) - _crossOptionIssues.AddRange(issues); - NotifyChanged(); - } - - public bool HasBlockingValidationErrors() - { - if (_fieldIssues.Values.Any(list => list.Any(i => i.Severity == FieldIssueSeverity.Error))) - return true; - return _crossOptionIssues.Any(i => i.Severity == FieldIssueSeverity.Error); - } - - public IReadOnlyList GetValidationSummary() - { - var list = new List(_fieldIssues.Values.SelectMany(x => x)); - list.AddRange(_crossOptionIssues); - return list; - } - - public void TrackCommit(EffectiveConfigEditCommittedArgs args) - { - _pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == args.SectionId && o.OptionName == args.OptionName); - if (!ValuesEqual(args.OldValue, args.NewValue)) - _pending.Add(new PendingOptionChange( - args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath)); - ActiveFieldKey = null; - NotifyChanged(); - } - - public void RevertChange(string sectionId, string optionName) - { - _pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == sectionId && o.OptionName == optionName); - ClearFieldIssues($"{sectionId}:{optionName}"); - NotifyChanged(); - } - - public void TrackManagedHostsChange(PendingManagedHostsChange change) - { - _pending.RemoveAll(c => c is PendingManagedHostsChange); - _pending.Add(change); - NotifyChanged(); - } - - public void RevertManagedHostsChange() - { - _pending.RemoveAll(c => c is PendingManagedHostsChange); - NotifyChanged(); - } - - public async Task ApplyAsync(CancellationToken ct = default) - { - if (_pending.Count == 0) - return EffectiveConfigSaveResult.NoChanges(); - - return await _saveService.SaveAsync(_pending.ToList(), ct); - } - - public void AcceptAppliedChanges(bool stayInEditMode) - { - _pending.Clear(); - _fieldIssues.Clear(); - _crossOptionIssues.Clear(); - ActiveFieldKey = null; - IsEditMode = stayInEditMode; - NotifyChanged(); - } - - private static IReadOnlyList? AsStringList(object? value) - { - return value as IReadOnlyList; - } - - private static bool ValuesEqual(object? oldValue, object? newValue) - { - var oldList = AsStringList(oldValue); - var newList = AsStringList(newValue); - if (oldList != null || newList != null) - return (oldList ?? Array.Empty()).SequenceEqual(newList ?? Array.Empty(), StringComparer.Ordinal); - return Equals(oldValue, newValue); - } + private void OnChildChanged() => Changed?.Invoke(); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs index e2b05be..de4ff9a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs @@ -27,8 +27,8 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor public void EnsureEditMode() { - if (!_session.IsEditMode) - _session.EnterEditMode(); + if (!_session.Ui.IsEditMode) + _session.Ui.EnterEditMode(); } public EffectiveConfigFieldRef Field(string sectionId, string optionName) @@ -69,8 +69,9 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor : descriptor.GetValue(); var oldValue = pending?.OldValue ?? baseValue; var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source; - _session.TrackCommit(new EffectiveConfigEditCommittedArgs( + _session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs( field.SectionId, field.OptionName, oldValue, newValue, source?.FilePath)); + _session.Ui.DeactivateField(); RefreshCrossOptionIssues(status); } @@ -84,8 +85,9 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor var baseValues = descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List(); var oldValues = pending?.OldValue is IReadOnlyList list ? list.ToList() : baseValues; var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source; - _session.TrackCommit(new EffectiveConfigEditCommittedArgs( + _session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs( field.SectionId, field.OptionName, oldValues, newValues.ToList(), source?.FilePath)); + _session.Ui.DeactivateField(); RefreshCrossOptionIssues(status); } @@ -105,36 +107,36 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor public void Revert(EffectiveConfigFieldRef field) { - _session.RevertChange(field.SectionId, field.OptionName); + _session.Draft.RevertChange(field.SectionId, field.OptionName); } public void SetFieldIssues(EffectiveConfigFieldRef field, IReadOnlyList issues) { - _session.SetFieldIssues(field.FieldKey, issues); + _session.Draft.SetFieldIssues(field.FieldKey, issues); } public void ClearFieldIssues(EffectiveConfigFieldRef field) { - _session.ClearFieldIssues(field.FieldKey); + _session.Draft.ClearFieldIssues(field.FieldKey); } public void RefreshCrossOptionIssues(DnsmasqServiceStatus status) { var issues = _crossOptionValidation.Validate( status, - _session.PendingChanges.OfType().ToList()); - _session.SetCrossOptionIssues(issues); + _session.Draft.PendingChanges.OfType().ToList()); + _session.Draft.SetCrossOptionIssues(issues); } public void Activate(EffectiveConfigFieldRef field) { EnsureEditMode(); - _session.ActivateField(field.FieldKey); + _session.Ui.ActivateField(field.FieldKey); } private PendingOptionChange? GetPendingOptionChange(EffectiveConfigFieldRef field) { - return _session.PendingChanges.OfType().FirstOrDefault(c => + return _session.Draft.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, field.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, field.OptionName, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigUiState.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigUiState.cs new file mode 100644 index 0000000..065991a --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigUiState.cs @@ -0,0 +1,38 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +public sealed class EffectiveConfigUiState : IEffectiveConfigUiState +{ + public event Action? Changed; + + public bool IsEditMode { get; private set; } + public string? ActiveFieldKey { get; private set; } + + public void EnterEditMode() + { + IsEditMode = true; + ActiveFieldKey = null; + Changed?.Invoke(); + } + + public void ExitEditMode() + { + IsEditMode = false; + ActiveFieldKey = null; + Changed?.Invoke(); + } + + public void ActivateField(string fieldKey) + { + IsEditMode = true; + ActiveFieldKey = fieldKey; + Changed?.Invoke(); + } + + public void DeactivateField() + { + ActiveFieldKey = null; + Changed?.Invoke(); + } +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ManagedHostsPendingChangeDiff.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ManagedHostsPendingChangeDiff.cs new file mode 100644 index 0000000..cabe065 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ManagedHostsPendingChangeDiff.cs @@ -0,0 +1,126 @@ +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +public enum ManagedHostsLineDiffKind +{ + Added, + Removed, + Modified, + /// Same entries (by id) but a different order in the file. + OrderOnly +} + +/// One line-level change for display in the save modal (before → after). +public readonly record struct ManagedHostsLineDiff( + ManagedHostsLineDiffKind Kind, + string? BeforeLine, + string? AfterLine); + +/// Builds a line-level diff between baseline and draft managed-hosts entries. +public static class ManagedHostsPendingChangeDiff +{ + public static IReadOnlyList Build(PendingManagedHostsChange change) => + Build(change.OldEntries, change.NewEntries); + + public static IReadOnlyList Build( + IReadOnlyList oldEntries, + IReadOnlyList newEntries) + { + var oldMap = ToUniqueMap(oldEntries); + var newMap = ToUniqueMap(newEntries); + var keys = oldMap.Keys.Union(newMap.Keys, StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal); + var list = new List(); + + foreach (var key in keys) + { + oldMap.TryGetValue(key, out var o); + newMap.TryGetValue(key, out var n); + + if (o == null && n != null) + list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.Added, null, FormatEntry(n))); + else if (o != null && n == null) + list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.Removed, FormatEntry(o), null)); + else if (o != null && n != null && !ContentEquals(o, n)) + list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.Modified, FormatEntry(o), FormatEntry(n))); + } + + if (list.Count == 0 && DetectOrderOnly(oldEntries, newEntries)) + list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.OrderOnly, null, null)); + + return list + .OrderBy(d => SortOrder(d)) + .ToList(); + } + + private static int SortOrder(ManagedHostsLineDiff d) => d.Kind switch + { + ManagedHostsLineDiffKind.Removed => 0, + ManagedHostsLineDiffKind.Modified => 1, + ManagedHostsLineDiffKind.OrderOnly => 2, + ManagedHostsLineDiffKind.Added => 3, + _ => 4 + }; + + private static bool DetectOrderOnly(IReadOnlyList oldList, IReadOnlyList newList) + { + if (oldList.Count != newList.Count || oldList.Count == 0) + return false; + var oldKeys = oldList.Select(e => StableKey(e)).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var newKeys = newList.Select(e => StableKey(e)).OrderBy(k => k, StringComparer.Ordinal).ToList(); + if (!oldKeys.SequenceEqual(newKeys, StringComparer.Ordinal)) + return false; + for (var i = 0; i < oldList.Count; i++) + { + if (!string.Equals(StableKey(oldList[i]), StableKey(newList[i]), StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static Dictionary ToUniqueMap(IReadOnlyList entries) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var e in entries) + { + var k = StableKey(e); + if (!map.ContainsKey(k)) + map[k] = e; + } + + return map; + } + + /// Matches identity: Id, or managed:line when Id is empty. + public static string StableKey(HostEntry e) => + !string.IsNullOrEmpty(e.Id) ? e.Id : $"managed:{e.LineNumber}"; + + public static string FormatEntry(HostEntry e) + { + if (e.IsPassthrough) + return string.IsNullOrEmpty(e.RawLine) ? "(unparsed line)" : e.RawLine.TrimEnd(); + if (e.IsComment) + return string.IsNullOrEmpty(e.RawLine) ? "#" : e.RawLine.TrimEnd(); + var names = e.Names ?? new List(); + if (string.IsNullOrWhiteSpace(e.Address) && names.Count == 0) + return "(empty)"; + return $"{e.Address} {string.Join(' ', names)}".Trim(); + } + + private static bool ContentEquals(HostEntry a, HostEntry b) + { + if (string.Equals(a.Address ?? "", b.Address ?? "", StringComparison.Ordinal)) + { + var an = a.Names ?? new List(); + var bn = b.Names ?? new List(); + if (an.SequenceEqual(bn, StringComparer.Ordinal) + && a.IsComment == b.IsComment + && a.IsPassthrough == b.IsPassthrough + && string.Equals(a.RawLine ?? "", b.RawLine ?? "", StringComparison.Ordinal)) + return true; + } + + return false; + } +} diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsEntryEditValidator.cs b/src/DnsmasqWebUI/Models/Hosts/HostsEntryEditValidator.cs new file mode 100644 index 0000000..8a2b9c7 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsEntryEditValidator.cs @@ -0,0 +1,35 @@ +using System.Net; + +namespace DnsmasqWebUI.Models.Hosts; + +/// Client-side checks for hosts table inline editing (IP + at least one name). +public static class HostsEntryEditValidator +{ + public static IReadOnlyList GetAddressFieldErrors(string addressTrimmed) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(addressTrimmed)) + errors.Add("Address is required."); + else if (!IPAddress.TryParse(addressTrimmed, out _)) + errors.Add("Address must be a valid IPv4 or IPv6 address."); + return errors; + } + + public static IReadOnlyList GetHostnameFieldErrors(IReadOnlyList names) + { + if (names == null || names.Count == 0) + return new[] { "At least one hostname is required." }; + return Array.Empty(); + } + + public static IReadOnlyList GetErrors(string addressTrimmed, IReadOnlyList names) + { + var list = new List(); + list.AddRange(GetAddressFieldErrors(addressTrimmed)); + list.AddRange(GetHostnameFieldErrors(names)); + return list; + } + + public static bool IsValid(string addressTrimmed, IReadOnlyList names) => + GetErrors(addressTrimmed, names).Count == 0; +} diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs b/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs index 42078b4..78a7a97 100644 --- a/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs +++ b/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs @@ -12,4 +12,6 @@ public sealed record HostsPageGroup( bool IsActive, string? InactiveReason, int VisibleRowCount, - IReadOnlyList Rows); + IReadOnlyList Rows, + /// True when entries in this source can be edited in the UI (e.g. managed hosts file). + bool IsSourceEditable); diff --git a/src/DnsmasqWebUI/wwwroot/js/modules/hosts-ui.js b/src/DnsmasqWebUI/wwwroot/js/modules/hosts-ui.js new file mode 100644 index 0000000..f9f74fe --- /dev/null +++ b/src/DnsmasqWebUI/wwwroot/js/modules/hosts-ui.js @@ -0,0 +1,12 @@ +/** + * Touch / coarse-pointer devices: show Effective names help as a toast instead of relying on hover. + */ +export function preferEffectiveNamesHelpToast() { + try { + if (window.matchMedia("(pointer: coarse)").matches) return true; + if (window.matchMedia("(hover: none)").matches) return true; + return false; + } catch { + return false; + } +}
Address NamesEffective names + Effective names + +
+ @EmptyGroupMessage +
- - - - @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))
@row.Address@string.Join(", ", row.Names) - @if (row.EffectiveNames.Count != row.Names.Count) - { - @string.Join(", ", row.EffectiveNames) - } - else - { - @string.Join(", ", row.EffectiveNames) - } -
+ + + + @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))
@row.Address@string.Join(", ", row.Names) + @if (row.EffectiveNames.Count != row.Names.Count) + { + @string.Join(", ", row.EffectiveNames) + } + else + { + @string.Join(", ", row.EffectiveNames) + } +