mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Hosts: inline file comments (hosts(5)) and Comment column
- HostEntry.InlineComment; parse at first # on data lines; ToLine + SanitizeInlineComment - HostsPageRow + HostsCache; pending change equality and save diff include comments - Hosts table: dedicated Comment column; edit in own cell; empty shown as em dash - Parser and pending-change tests
This commit is contained in:
@@ -35,4 +35,19 @@ public class PendingManagedHostsChangeTests
|
|||||||
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||||
Assert.True(pending.HasChanges);
|
Assert.True(pending.HasChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasChanges_WhenInlineCommentDiffers_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var oldEntries = new List<HostEntry>
|
||||||
|
{
|
||||||
|
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "old" }
|
||||||
|
};
|
||||||
|
var newEntries = new List<HostEntry>
|
||||||
|
{
|
||||||
|
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "new" }
|
||||||
|
};
|
||||||
|
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||||
|
Assert.True(pending.HasChanges);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -139,6 +139,17 @@ public class HostsFileLineParserTests
|
|||||||
Assert.Equal(address, e.Address);
|
Assert.Equal(address, e.Address);
|
||||||
Assert.Single(e.Names);
|
Assert.Single(e.Names);
|
||||||
Assert.Equal(name, e.Names[0]);
|
Assert.Equal(name, e.Names[0]);
|
||||||
|
Assert.Equal(comment, e.InlineComment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToLine_WithInlineComment_AppendsHashAndRoundTrips()
|
||||||
|
{
|
||||||
|
var line = "192.168.0.5 router # edge gateway";
|
||||||
|
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||||
|
Assert.NotNull(e);
|
||||||
|
var back = HostsFileLineParser.ToLine(e!);
|
||||||
|
Assert.Equal(line, back);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@using DnsmasqWebUI.Models.Hosts
|
@using DnsmasqWebUI.Models.Hosts
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
|
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig
|
||||||
@using DnsmasqWebUI.Components.EffectiveConfig
|
@using DnsmasqWebUI.Components.EffectiveConfig
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@@ -92,6 +93,7 @@ else
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Address</th>
|
<th scope="col">Address</th>
|
||||||
<th scope="col">Names</th>
|
<th scope="col">Names</th>
|
||||||
|
<th scope="col" class="hosts-comment-col" title="Inline hosts(5) comment: written after # on the same line when saved.">Comment</th>
|
||||||
<th scope="col" class="hosts-effective-names-th">
|
<th scope="col" class="hosts-effective-names-th">
|
||||||
<span class="align-middle">Effective names</span>
|
<span class="align-middle">Effective names</span>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -109,7 +111,7 @@ else
|
|||||||
{
|
{
|
||||||
<tbody @key="@group.Key">
|
<tbody @key="@group.Key">
|
||||||
<tr class="hosts-group-header">
|
<tr class="hosts-group-header">
|
||||||
<th colspan="3" scope="colgroup" class="hosts-group-header-cell">
|
<th colspan="4" scope="colgroup" class="hosts-group-header-cell">
|
||||||
<div class="hosts-group-header__content">
|
<div class="hosts-group-header__content">
|
||||||
<div class="hosts-group-header__title">
|
<div class="hosts-group-header__title">
|
||||||
<span>@group.Title</span>
|
<span>@group.Title</span>
|
||||||
@@ -153,7 +155,7 @@ else
|
|||||||
@if (group.Rows.Count == 0)
|
@if (group.Rows.Count == 0)
|
||||||
{
|
{
|
||||||
<tr class="hosts-row hosts-row--empty" @key="@("empty:" + group.Key)">
|
<tr class="hosts-row hosts-row--empty" @key="@("empty:" + group.Key)">
|
||||||
<td colspan="3" class="text-muted small text-center hosts-empty-group-message py-3">
|
<td colspan="4" class="text-muted small text-center hosts-empty-group-message py-3">
|
||||||
@EmptyGroupMessage
|
@EmptyGroupMessage
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -187,6 +189,14 @@ else
|
|||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnEditFieldKeyDown" />
|
@onkeydown="OnEditFieldKeyDown" />
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="form-control form-control-sm"
|
||||||
|
placeholder="Optional"
|
||||||
|
title="Saved after # on the same line as this entry (inline hosts comment)."
|
||||||
|
@bind="_editInlineComment"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeydown="OnEditFieldKeyDown" />
|
||||||
|
</td>
|
||||||
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
|
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -198,6 +208,16 @@ else
|
|||||||
@ondblclick="@(async () => await OnRowDoubleClickAsync(row))">
|
@ondblclick="@(async () => await OnRowDoubleClickAsync(row))">
|
||||||
<td><code>@row.Address</code></td>
|
<td><code>@row.Address</code></td>
|
||||||
<td>@string.Join(", ", row.Names)</td>
|
<td>@string.Join(", ", row.Names)</td>
|
||||||
|
<td class="text-muted small hosts-comment-col-cell">
|
||||||
|
@if (string.IsNullOrWhiteSpace(row.InlineComment))
|
||||||
|
{
|
||||||
|
<span class="hosts-comment-empty">—</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="hosts-row-inline-comment">@row.InlineComment</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (row.EffectiveNames.Count != row.Names.Count)
|
@if (row.EffectiveNames.Count != row.Names.Count)
|
||||||
{
|
{
|
||||||
@@ -287,6 +307,7 @@ else
|
|||||||
private string? _editingRowId;
|
private string? _editingRowId;
|
||||||
private string _editAddress = "";
|
private string _editAddress = "";
|
||||||
private string _editNames = "";
|
private string _editNames = "";
|
||||||
|
private string _editInlineComment = "";
|
||||||
private ElementReference _editingRowRef;
|
private ElementReference _editingRowRef;
|
||||||
private ElementReference _editAddressInputRef;
|
private ElementReference _editAddressInputRef;
|
||||||
private IJSObjectReference? _hostsTableScrollCueModule;
|
private IJSObjectReference? _hostsTableScrollCueModule;
|
||||||
@@ -462,6 +483,7 @@ else
|
|||||||
_editingRowId = row.Id;
|
_editingRowId = row.Id;
|
||||||
_editAddress = entry.Address;
|
_editAddress = entry.Address;
|
||||||
_editNames = string.Join(", ", entry.Names);
|
_editNames = string.Join(", ", entry.Names);
|
||||||
|
_editInlineComment = entry.InlineComment ?? "";
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +524,7 @@ else
|
|||||||
_editingRowId = id;
|
_editingRowId = id;
|
||||||
_editAddress = "";
|
_editAddress = "";
|
||||||
_editNames = "";
|
_editNames = "";
|
||||||
|
_editInlineComment = "";
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +533,7 @@ else
|
|||||||
_editingRowId = null;
|
_editingRowId = null;
|
||||||
_editAddress = "";
|
_editAddress = "";
|
||||||
_editNames = "";
|
_editNames = "";
|
||||||
|
_editInlineComment = "";
|
||||||
_showHostFieldValidation = false;
|
_showHostFieldValidation = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,6 +663,7 @@ else
|
|||||||
|
|
||||||
entry.Address = trimmedAddr;
|
entry.Address = trimmedAddr;
|
||||||
entry.Names = newNames;
|
entry.Names = newNames;
|
||||||
|
entry.InlineComment = HostsFileLineParser.SanitizeInlineComment(_editInlineComment);
|
||||||
Session.Draft.SetManagedHostsDraft(
|
Session.Draft.SetManagedHostsDraft(
|
||||||
CloneEntries(_baselineManagedEntries),
|
CloneEntries(_baselineManagedEntries),
|
||||||
list,
|
list,
|
||||||
@@ -674,7 +699,8 @@ else
|
|||||||
Names = e.Names != null ? new List<string>(e.Names) : new List<string>(),
|
Names = e.Names != null ? new List<string>(e.Names) : new List<string>(),
|
||||||
RawLine = e.RawLine,
|
RawLine = e.RawLine,
|
||||||
IsComment = e.IsComment,
|
IsComment = e.IsComment,
|
||||||
IsPassthrough = e.IsPassthrough
|
IsPassthrough = e.IsPassthrough,
|
||||||
|
InlineComment = e.InlineComment
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
private MarkupString RenderAddnHostsList()
|
private MarkupString RenderAddnHostsList()
|
||||||
@@ -830,7 +856,8 @@ else
|
|||||||
Names: names,
|
Names: names,
|
||||||
EffectiveNames: effective,
|
EffectiveNames: effective,
|
||||||
IsComment: e.IsComment,
|
IsComment: e.IsComment,
|
||||||
LineNumber: e.LineNumber));
|
LineNumber: e.LineNumber,
|
||||||
|
InlineComment: e.InlineComment));
|
||||||
}
|
}
|
||||||
|
|
||||||
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
|
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
|
||||||
|
|||||||
@@ -101,6 +101,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Group header row (colspan, not rowspan) */
|
/* Group header row (colspan, not rowspan) */
|
||||||
|
::deep .hosts-table .hosts-comment-col,
|
||||||
|
::deep .hosts-table .hosts-comment-col-cell {
|
||||||
|
max-width: 14rem;
|
||||||
|
width: 18%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .hosts-table .hosts-comment-empty {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .hosts-table .hosts-row-inline-comment {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
::deep .hosts-table .hosts-effective-names-th {
|
::deep .hosts-table .hosts-effective-names-th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-1
@@ -40,6 +40,8 @@ public static class HostsFileLineParser
|
|||||||
if (string.IsNullOrEmpty(trimmed))
|
if (string.IsNullOrEmpty(trimmed))
|
||||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||||
|
|
||||||
|
if (trimmed.StartsWith('#'))
|
||||||
|
{
|
||||||
var result = LineContent.TryParse(trimmed);
|
var result = LineContent.TryParse(trimmed);
|
||||||
if (!result.HasValue)
|
if (!result.HasValue)
|
||||||
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||||
@@ -58,11 +60,65 @@ public static class HostsFileLineParser
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string? inlineComment = null;
|
||||||
|
var dataPart = trimmed;
|
||||||
|
var hashIdx = trimmed.IndexOf('#');
|
||||||
|
if (hashIdx >= 0)
|
||||||
|
{
|
||||||
|
inlineComment = hashIdx + 1 < trimmed.Length ? trimmed[(hashIdx + 1)..].Trim() : null;
|
||||||
|
if (string.IsNullOrEmpty(inlineComment))
|
||||||
|
inlineComment = null;
|
||||||
|
dataPart = trimmed[..hashIdx].TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(dataPart))
|
||||||
|
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||||
|
|
||||||
|
var dataResult = LineContent.TryParse(dataPart);
|
||||||
|
if (!dataResult.HasValue)
|
||||||
|
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
|
||||||
|
|
||||||
|
var (isCommentData, addressData, namesData) = dataResult.Value;
|
||||||
|
if (string.IsNullOrEmpty(addressData) && namesData.Count == 0)
|
||||||
|
return new HostEntry
|
||||||
|
{
|
||||||
|
LineNumber = lineNumber,
|
||||||
|
RawLine = line,
|
||||||
|
IsComment = isCommentData,
|
||||||
|
IsPassthrough = true,
|
||||||
|
InlineComment = inlineComment
|
||||||
|
};
|
||||||
|
|
||||||
|
return new HostEntry
|
||||||
|
{
|
||||||
|
LineNumber = lineNumber,
|
||||||
|
Address = addressData,
|
||||||
|
Names = namesData,
|
||||||
|
RawLine = line,
|
||||||
|
IsComment = isCommentData,
|
||||||
|
InlineComment = inlineComment
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static string ToLine(HostEntry entry)
|
public static string ToLine(HostEntry entry)
|
||||||
{
|
{
|
||||||
if (entry.IsPassthrough)
|
if (entry.IsPassthrough)
|
||||||
return entry.RawLine;
|
return entry.RawLine;
|
||||||
var prefix = entry.IsComment ? "# " : "";
|
var prefix = entry.IsComment ? "# " : "";
|
||||||
return prefix + entry.Address + " " + string.Join(" ", entry.Names);
|
var core = prefix + entry.Address + " " + string.Join(" ", entry.Names);
|
||||||
|
var sanitized = SanitizeInlineComment(entry.InlineComment);
|
||||||
|
if (sanitized != null)
|
||||||
|
return core.TrimEnd() + " # " + sanitized;
|
||||||
|
return core;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Strip characters that would break a hosts line; drop <c>#</c> (comment delimiter).</summary>
|
||||||
|
public static string? SanitizeInlineComment(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return null;
|
||||||
|
var flattened = text.Replace('\r', ' ').Replace('\n', ' ').Replace('#', ' ');
|
||||||
|
var collapsed = string.Join(' ', flattened.Split(' ', StringSplitOptions.RemoveEmptyEntries)).Trim();
|
||||||
|
return collapsed.Length == 0 ? null : collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,7 +324,8 @@ public sealed class HostsCache : IHostsCache, IDisposable
|
|||||||
Names: names,
|
Names: names,
|
||||||
EffectiveNames: effectiveNames,
|
EffectiveNames: effectiveNames,
|
||||||
IsComment: entry.IsComment,
|
IsComment: entry.IsComment,
|
||||||
LineNumber: entry.LineNumber));
|
LineNumber: entry.LineNumber,
|
||||||
|
InlineComment: entry.InlineComment));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-only files (system, addn-hosts, hostsdir)
|
// Read-only files (system, addn-hosts, hostsdir)
|
||||||
@@ -352,7 +353,8 @@ public sealed class HostsCache : IHostsCache, IDisposable
|
|||||||
Names: names,
|
Names: names,
|
||||||
EffectiveNames: effectiveNames,
|
EffectiveNames: effectiveNames,
|
||||||
IsComment: entry.IsComment,
|
IsComment: entry.IsComment,
|
||||||
LineNumber: entry.LineNumber));
|
LineNumber: entry.LineNumber,
|
||||||
|
InlineComment: entry.InlineComment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ public static class ManagedHostsPendingChangeDiff
|
|||||||
var names = e.Names ?? new List<string>();
|
var names = e.Names ?? new List<string>();
|
||||||
if (string.IsNullOrWhiteSpace(e.Address) && names.Count == 0)
|
if (string.IsNullOrWhiteSpace(e.Address) && names.Count == 0)
|
||||||
return "(empty)";
|
return "(empty)";
|
||||||
return $"{e.Address} {string.Join(' ', names)}".Trim();
|
var core = $"{e.Address} {string.Join(' ', names)}".Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(e.InlineComment))
|
||||||
|
return core;
|
||||||
|
return $"{core} # {e.InlineComment}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ContentEquals(HostEntry a, HostEntry b)
|
private static bool ContentEquals(HostEntry a, HostEntry b)
|
||||||
@@ -117,7 +120,8 @@ public static class ManagedHostsPendingChangeDiff
|
|||||||
if (an.SequenceEqual(bn, StringComparer.Ordinal)
|
if (an.SequenceEqual(bn, StringComparer.Ordinal)
|
||||||
&& a.IsComment == b.IsComment
|
&& a.IsComment == b.IsComment
|
||||||
&& a.IsPassthrough == b.IsPassthrough
|
&& a.IsPassthrough == b.IsPassthrough
|
||||||
&& string.Equals(a.RawLine ?? "", b.RawLine ?? "", StringComparison.Ordinal))
|
&& string.Equals(a.RawLine ?? "", b.RawLine ?? "", StringComparison.Ordinal)
|
||||||
|
&& string.Equals(a.InlineComment ?? "", b.InlineComment ?? "", StringComparison.Ordinal))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ public sealed record PendingManagedHostsChange(
|
|||||||
!(a.Names ?? new List<string>()).SequenceEqual(b.Names ?? new List<string>(), StringComparer.Ordinal) ||
|
!(a.Names ?? new List<string>()).SequenceEqual(b.Names ?? new List<string>(), StringComparer.Ordinal) ||
|
||||||
a.RawLine != b.RawLine ||
|
a.RawLine != b.RawLine ||
|
||||||
a.IsComment != b.IsComment ||
|
a.IsComment != b.IsComment ||
|
||||||
a.IsPassthrough != b.IsPassthrough)
|
a.IsPassthrough != b.IsPassthrough ||
|
||||||
|
!string.Equals(a.InlineComment ?? "", b.InlineComment ?? "", StringComparison.Ordinal))
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ public class HostEntry
|
|||||||
|
|
||||||
/// <summary>True when the line could not be parsed as address/names (e.g. malformed or comment); preserved as RawLine on write.</summary>
|
/// <summary>True when the line could not be parsed as address/names (e.g. malformed or comment); preserved as RawLine on write.</summary>
|
||||||
public bool IsPassthrough { get; set; }
|
public bool IsPassthrough { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Text after <c>#</c> on the same line as address/names (hosts(5) inline comment). Not used for full-line comments.</summary>
|
||||||
|
public string? InlineComment { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ public sealed record HostsPageRow(
|
|||||||
IReadOnlyList<string> Names,
|
IReadOnlyList<string> Names,
|
||||||
IReadOnlyList<string> EffectiveNames,
|
IReadOnlyList<string> EffectiveNames,
|
||||||
bool IsComment,
|
bool IsComment,
|
||||||
int LineNumber);
|
int LineNumber,
|
||||||
|
string? InlineComment = null);
|
||||||
|
|||||||
Reference in New Issue
Block a user