mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +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");
|
||||
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.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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+57
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user