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:
2026-04-01 11:52:21 +10:00
parent f0b4ea97a4
commit eae29de045
10 changed files with 154 additions and 19 deletions
@@ -35,4 +35,19 @@ public class PendingManagedHostsChangeTests
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
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);
}
}
@@ -139,6 +139,17 @@ public class HostsFileLineParserTests
Assert.Equal(address, e.Address);
Assert.Single(e.Names);
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]
+31 -4
View File
@@ -7,6 +7,7 @@
@using DnsmasqWebUI.Models.Hosts
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig
@using DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@using Microsoft.AspNetCore.Components.Web
@@ -92,6 +93,7 @@ else
<tr>
<th scope="col">Address</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">
<span class="align-middle">Effective names</span>
<button type="button"
@@ -109,7 +111,7 @@ else
{
<tbody @key="@group.Key">
<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__title">
<span>@group.Title</span>
@@ -153,7 +155,7 @@ else
@if (group.Rows.Count == 0)
{
<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
</td>
</tr>
@@ -187,6 +189,14 @@ else
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown" />
</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>
</tr>
}
@@ -198,6 +208,16 @@ else
@ondblclick="@(async () => await OnRowDoubleClickAsync(row))">
<td><code>@row.Address</code></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>
@if (row.EffectiveNames.Count != row.Names.Count)
{
@@ -287,6 +307,7 @@ else
private string? _editingRowId;
private string _editAddress = "";
private string _editNames = "";
private string _editInlineComment = "";
private ElementReference _editingRowRef;
private ElementReference _editAddressInputRef;
private IJSObjectReference? _hostsTableScrollCueModule;
@@ -462,6 +483,7 @@ else
_editingRowId = row.Id;
_editAddress = entry.Address;
_editNames = string.Join(", ", entry.Names);
_editInlineComment = entry.InlineComment ?? "";
await InvokeAsync(StateHasChanged);
}
@@ -502,6 +524,7 @@ else
_editingRowId = id;
_editAddress = "";
_editNames = "";
_editInlineComment = "";
StateHasChanged();
}
@@ -510,6 +533,7 @@ else
_editingRowId = null;
_editAddress = "";
_editNames = "";
_editInlineComment = "";
_showHostFieldValidation = false;
}
@@ -639,6 +663,7 @@ else
entry.Address = trimmedAddr;
entry.Names = newNames;
entry.InlineComment = HostsFileLineParser.SanitizeInlineComment(_editInlineComment);
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
@@ -674,7 +699,8 @@ else
Names = e.Names != null ? new List<string>(e.Names) : new List<string>(),
RawLine = e.RawLine,
IsComment = e.IsComment,
IsPassthrough = e.IsPassthrough
IsPassthrough = e.IsPassthrough,
InlineComment = e.InlineComment
}).ToList();
private MarkupString RenderAddnHostsList()
@@ -830,7 +856,8 @@ else
Names: names,
EffectiveNames: effective,
IsComment: e.IsComment,
LineNumber: e.LineNumber));
LineNumber: e.LineNumber,
InlineComment: e.InlineComment));
}
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
@@ -101,6 +101,21 @@
}
/* 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 {
white-space: nowrap;
}
@@ -40,6 +40,8 @@ public static class HostsFileLineParser
if (string.IsNullOrEmpty(trimmed))
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
if (trimmed.StartsWith('#'))
{
var result = LineContent.TryParse(trimmed);
if (!result.HasValue)
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)
{
if (entry.IsPassthrough)
return entry.RawLine;
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,
EffectiveNames: effectiveNames,
IsComment: entry.IsComment,
LineNumber: entry.LineNumber));
LineNumber: entry.LineNumber,
InlineComment: entry.InlineComment));
}
// Read-only files (system, addn-hosts, hostsdir)
@@ -352,7 +353,8 @@ public sealed class HostsCache : IHostsCache, IDisposable
Names: names,
EffectiveNames: effectiveNames,
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>();
if (string.IsNullOrWhiteSpace(e.Address) && names.Count == 0)
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)
@@ -117,7 +120,8 @@ public static class ManagedHostsPendingChangeDiff
if (an.SequenceEqual(bn, StringComparer.Ordinal)
&& a.IsComment == b.IsComment
&& 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;
}
@@ -24,7 +24,8 @@ public sealed record PendingManagedHostsChange(
!(a.Names ?? new List<string>()).SequenceEqual(b.Names ?? new List<string>(), StringComparer.Ordinal) ||
a.RawLine != b.RawLine ||
a.IsComment != b.IsComment ||
a.IsPassthrough != b.IsPassthrough)
a.IsPassthrough != b.IsPassthrough ||
!string.Equals(a.InlineComment ?? "", b.InlineComment ?? "", StringComparison.Ordinal))
return false;
}
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>
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> EffectiveNames,
bool IsComment,
int LineNumber);
int LineNumber,
string? InlineComment = null);