diff --git a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsSourcePathDisplayTests.cs b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsSourcePathDisplayTests.cs new file mode 100644 index 0000000..3c7df26 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsSourcePathDisplayTests.cs @@ -0,0 +1,55 @@ +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Tests.Models.Hosts; + +public sealed class HostsSourcePathDisplayTests +{ + private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) => + new( + Id: "id", + SourceKind: kind, + SourcePath: path, + IsEditable: false, + IsActive: true, + InactiveReason: null, + Address: "127.0.0.1", + Names: [], + EffectiveNames: [], + IsComment: isComment, + LineNumber: 1); + + [Fact] + public void BuildFileFilterMenuGroups_InjectsManagedPath_WhenNotInRows() + { + var managed = "/data/managed.hosts"; + var groups = HostsSourcePathDisplay.BuildFileFilterMenuGroups([], managed); + + Assert.Single(groups); + Assert.Equal(HostsRowSourceKind.Managed, groups[0].SourceKind); + Assert.Single(groups[0].Files); + Assert.Equal(managed, groups[0].Files[0].Path); + Assert.Equal(0, groups[0].Files[0].RecordCount); + } + + [Fact] + public void BuildFileFilterMenuGroups_OrdersGroupsManagedSystemAddn_AndCountsNonComments() + { + var rows = new[] + { + Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"), + Row(HostsRowSourceKind.Managed, "/m/a.hosts"), + Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true), + Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"), + }; + + var groups = HostsSourcePathDisplay.BuildFileFilterMenuGroups(rows, managedHostsPathAlwaysShow: null); + + Assert.Equal(3, groups.Count); + Assert.Equal(HostsRowSourceKind.Managed, groups[0].SourceKind); + Assert.Equal(HostsRowSourceKind.SystemHosts, groups[1].SourceKind); + Assert.Equal(HostsRowSourceKind.AddnHosts, groups[2].SourceKind); + + Assert.Single(groups[0].Files); + Assert.Equal(1, groups[0].Files[0].RecordCount); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Hosts/HostsPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Hosts/HostsPageProjectionServiceTests.cs new file mode 100644 index 0000000..421245c --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/Hosts/HostsPageProjectionServiceTests.cs @@ -0,0 +1,80 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Tests.Services.Hosts; + +public class HostsPageProjectionServiceTests +{ + private readonly HostsPageProjectionService _sut = new(); + + [Fact] + public void BuildGroups_SourcePathFilter_KeepsOnlyThatFile() + { + var rows = new HostsPageRow[] + { + new( + "m1", + HostsRowSourceKind.Managed, + "/data/managed.hosts", + IsEditable: true, + IsActive: true, + InactiveReason: null, + Address: "10.0.0.1", + Names: new[] { "a" }, + EffectiveNames: new[] { "a" }, + IsComment: false, + LineNumber: 1), + new( + "x1", + HostsRowSourceKind.AddnHosts, + "/etc/extra.hosts", + IsEditable: false, + IsActive: true, + InactiveReason: null, + Address: "10.0.0.2", + Names: new[] { "b" }, + EffectiveNames: new[] { "b" }, + IsComment: false, + LineNumber: 1), + }; + + var query = new HostsPageQueryState( + Search: "", + Grouping: HostsGroupingMode.Source, + Sort: HostsSortMode.LineNumber, + Descending: false, + SourceKindFilter: null, + SourcePathFilter: "/etc/extra.hosts", + EditableFilter: null, + ActiveFilter: null); + + var groups = _sut.BuildGroups(rows, query); + + Assert.Single(groups); + Assert.Equal("/etc/extra.hosts", groups[0].Subtitle); + Assert.Single(groups[0].Rows); + } + + [Fact] + public void BuildGroups_SourcePathFilter_EmptyMeansAllFiles() + { + var rows = new HostsPageRow[] + { + new("m1", HostsRowSourceKind.Managed, "/a", true, true, null, "1.1.1.1", new[] { "a" }, new[] { "a" }, false, 1), + new("x1", HostsRowSourceKind.AddnHosts, "/b", false, true, null, "2.2.2.2", new[] { "b" }, new[] { "b" }, false, 1), + }; + + var query = new HostsPageQueryState( + "", + HostsGroupingMode.Source, + HostsSortMode.LineNumber, + false, + null, + SourcePathFilter: null, + null, + null); + + var groups = _sut.BuildGroups(rows, query); + Assert.Equal(2, groups.Count); + } +} diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 6b9e7d0..472e3de 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -69,19 +69,79 @@ else

Hosts entries

-
-
+
+
+ +
+ @if (_fileFilterMenuOpen) + { + + } + + @if (_fileFilterMenuOpen) + { +
+ + @foreach (var group in FileFilterMenuGroups) + { +
+ + @foreach (var file in group.Files) + { + var path = file.Path; + var active = IsSourceFilePathSelected(path); + + } +
+ } +
+ } +
@if (VisibleGroups.Count == 0) { -

No hosts entries found.

+

@(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "No hosts entries found." : "No hosts entries for the selected file.")

} else { @@ -89,6 +149,10 @@ else {

No rows match the current search.

} + else if (TotalVisibleDataRows == 0 && !string.IsNullOrWhiteSpace(_sourceFileFilter)) + { +

No rows match the current filters.

+ }
@@ -128,7 +192,7 @@ else Inactive }
-
+
@if (!string.IsNullOrWhiteSpace(group.Subtitle)) { @@ -406,6 +470,8 @@ else private string? _error; private string _search = ""; + /// Empty = all hosts files; otherwise matches . + private string _sourceFileFilter = ""; private readonly HashSet _selectedManagedRowIds = new(StringComparer.Ordinal); @@ -431,6 +497,7 @@ else private object? _visibleGroupsCacheRowsRef; private string _visibleGroupsCacheSearch = "\0"; private string _visibleGroupsCacheManagedPath = "\0"; + private string _visibleGroupsCacheSourcePathFilter = "\0"; private IReadOnlyList VisibleGroups { @@ -440,7 +507,8 @@ else if (_visibleGroupsCache != null && ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows) && _visibleGroupsCacheSearch == _search - && _visibleGroupsCacheManagedPath == managedPath) + && _visibleGroupsCacheManagedPath == managedPath + && _visibleGroupsCacheSourcePathFilter == _sourceFileFilter) { return _visibleGroupsCache; } @@ -450,6 +518,7 @@ else _visibleGroupsCacheRowsRef = _unifiedRows; _visibleGroupsCacheSearch = _search; _visibleGroupsCacheManagedPath = managedPath; + _visibleGroupsCacheSourcePathFilter = _sourceFileFilter; return _visibleGroupsCache; } } @@ -512,6 +581,10 @@ else if (groups.Any(g => g.SourceKind == HostsRowSourceKind.Managed)) return groups; + if (!string.IsNullOrWhiteSpace(_sourceFileFilter) + && !string.Equals(_sourceFileFilter.Trim(), path, StringComparison.OrdinalIgnoreCase)) + return groups; + var placeholder = new HostsPageGroup( Key: $"{HostsRowSourceKind.Managed}:{path}", Title: "Managed hosts", @@ -535,10 +608,99 @@ else Sort: HostsSortMode.LineNumber, Descending: false, SourceKindFilter: null, + SourcePathFilter: string.IsNullOrWhiteSpace(_sourceFileFilter) ? null : _sourceFileFilter.Trim(), EditableFilter: null, ActiveFilter: null); } + private bool _fileFilterMenuOpen; + + private IReadOnlyList FileFilterMenuGroups => + HostsSourcePathDisplay.BuildFileFilterMenuGroups(_unifiedRows, _status?.ManagedHostsFilePath); + + private int HostsTotalDataRowCount => _unifiedRows.Count(r => !r.IsComment); + + private string FileFilterSummaryText + { + get + { + if (string.IsNullOrWhiteSpace(_sourceFileFilter)) + { + var n = HostsTotalDataRowCount; + return n == 0 ? "All files" : $"All files · {n} records"; + } + + var trimmed = _sourceFileFilter.Trim(); + foreach (var g in FileFilterMenuGroups) + { + foreach (var f in g.Files) + { + if (string.Equals(f.Path, trimmed, StringComparison.OrdinalIgnoreCase)) + { + var prefix = HostsSourcePathDisplay.GroupSummaryPrefix(g.SourceKind); + return $"{prefix} · {f.FileName} ({f.RecordCount})"; + } + } + } + + return HostsSourcePathDisplay.FormatOptionLabel(trimmed); + } + } + + private void ToggleFileFilterMenu() => _fileFilterMenuOpen = !_fileFilterMenuOpen; + + private void CloseFileFilterMenu() => _fileFilterMenuOpen = false; + + private void SelectSourceFileFilter(string path) + { + _sourceFileFilter = path ?? ""; + CloseFileFilterMenu(); + OnSourceFileFilterChangedAfterBind(); + } + + private async Task OnHostsToolbarKeyDown(KeyboardEventArgs e) + { + if (!_fileFilterMenuOpen || e.Key != "Escape") + return; + CloseFileFilterMenu(); + await InvokeAsync(StateHasChanged); + } + + private bool IsSourceFilePathSelected(string path) => + !string.IsNullOrWhiteSpace(_sourceFileFilter) + && string.Equals(_sourceFileFilter.Trim(), path.Trim(), StringComparison.OrdinalIgnoreCase); + + private static string FileFilterGroupKindClass(HostsRowSourceKind kind) => kind switch + { + HostsRowSourceKind.Managed => "managed", + HostsRowSourceKind.SystemHosts => "system", + HostsRowSourceKind.AddnHosts => "addn", + HostsRowSourceKind.Hostsdir => "hostsdir", + _ => "other" + }; + + private void OnSourceFileFilterChangedAfterBind() + { + InvalidateVisibleGroupsCache(); + StateHasChanged(); + } + + private void NormalizeSourceFileFilterIfStale() + { + if (string.IsNullOrWhiteSpace(_sourceFileFilter)) + return; + var f = _sourceFileFilter.Trim(); + var inRows = _unifiedRows.Any(r => string.Equals(r.SourcePath, f, StringComparison.OrdinalIgnoreCase)); + var managed = _status?.ManagedHostsFilePath?.Trim(); + var isManagedPath = !string.IsNullOrEmpty(managed) + && string.Equals(managed, f, StringComparison.OrdinalIgnoreCase); + if (!inRows && !isManagedPath) + { + _sourceFileFilter = ""; + InvalidateVisibleGroupsCache(); + } + } + private void InvalidateVisibleGroupsCache() { _visibleGroupsCache = null; @@ -777,6 +939,13 @@ else if (_showRemoveHostsModal || _showInvalidHostEditModal) return; + if (_fileFilterMenuOpen && e.Key == "Escape") + { + CloseFileFilterMenu(); + await InvokeAsync(StateHasChanged); + return; + } + if (IsInlineEditing) { // Scroll region is focusable (tabindex=0); after clicking outside inputs, Escape must still exit edit. @@ -1193,6 +1362,7 @@ else _unifiedRows = Array.Empty(); } + NormalizeSourceFileFilterIfStale(); InvalidateVisibleGroupsCache(); _selectedManagedRowIds.Clear(); _selectionAnchorRowId = null; @@ -1235,6 +1405,7 @@ else } _unifiedRows = managedRows.Concat(_restUnifiedRows).ToList(); + NormalizeSourceFileFilterIfStale(); ClearStaleInlineEditState(); PruneManagedSelectionToExistingEditableRows(); } diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css index b546f97..4042433 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css @@ -58,9 +58,214 @@ box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.35); } +/* Search + file filter on one row; search grows, select keeps a stable width. */ +.hosts-toolbar-fields { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 0.5rem; + min-width: 0; +} + .hosts-toolbar-search { - min-width: 14rem; - max-width: 24rem; + flex: 1 1 12rem; + min-width: 0; + width: auto; + max-width: none; +} + +/* File filter: wrapper keeps toolbar flex slot; menu layers above table when open. */ +.hosts-file-filter-wrap { + position: relative; + flex: 0 0 auto; + min-width: 13.5rem; + max-width: 22rem; + width: auto; +} + +.hosts-file-filter-wrap--open { + z-index: 1080; +} + +.hosts-file-filter-backdrop { + position: fixed; + inset: 0; + z-index: 1070; + background: transparent; +} + +.hosts-file-filter-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + width: 100%; + padding: 0.25rem 0.55rem; + font-size: 0.875rem; + line-height: 1.5; + text-align: left; + color: var(--bs-body-color); + background-color: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius-sm); + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.hosts-file-filter-toggle:hover { + border-color: #bfc7d4; +} + +.hosts-file-filter-wrap--open .hosts-file-filter-toggle { + border-color: #86b7fe; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.2); +} + +.hosts-file-filter-toggle-label { + min-width: 0; +} + +.hosts-file-filter-chevron { + font-size: 0.75rem; + opacity: 0.65; + transition: transform 0.15s ease; +} + +.hosts-file-filter-wrap--open .hosts-file-filter-chevron { + transform: rotate(180deg); +} + +.hosts-file-filter-panel { + position: absolute; + top: calc(100% + 4px); + right: 0; + left: auto; + min-width: 100%; + width: max-content; + max-width: min(26rem, 92vw); + max-height: min(22rem, 55vh); + overflow-x: hidden; + overflow-y: auto; + z-index: 1085; + padding: 0.35rem; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #e2e8f0); + border-radius: 10px; +} + +.hosts-file-filter-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + width: 100%; + padding: 0.4rem 0.55rem; + margin: 0; + font-size: 0.8125rem; + line-height: 1.35; + text-align: left; + color: #1e293b; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.hosts-file-filter-option:hover { + background: rgba(15, 23, 42, 0.06); +} + +.hosts-file-filter-option--active { + background: rgba(13, 110, 253, 0.12); + font-weight: 600; +} + +.hosts-file-filter-option--active:hover { + background: rgba(13, 110, 253, 0.18); +} + +.hosts-file-filter-option--all { + margin-bottom: 0.25rem; + border: 1px solid #e7ebf0; + background: linear-gradient(180deg, #fbfcfe 0%, #f4f6f9 100%); +} + +.hosts-file-filter-option-main { + flex: 1 1 0%; + min-width: 0; +} + +/* Full path when width allows; when truncated, keep the end (usually the filename) visible. Full path in title for hover. */ +.hosts-file-filter-path { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + +.hosts-file-filter-count { + flex-shrink: 0; + padding: 0.1rem 0.45rem; + font-size: 0.7rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.3; + color: #475569; + background: rgba(15, 23, 42, 0.06); + border-radius: 999px; +} + +.hosts-file-filter-option--active .hosts-file-filter-count { + color: #0d47a1; + background: rgba(13, 110, 253, 0.2); +} + +.hosts-file-filter-group { + margin-top: 0.35rem; + padding: 0.3rem 0.35rem 0.45rem; + border-radius: 8px; + border: 1px solid rgba(15, 23, 42, 0.08); +} + +.hosts-file-filter-group:first-of-type { + margin-top: 0; +} + +.hosts-file-filter-group--managed { + background: linear-gradient(180deg, #e8f4fd 0%, #dceef9 100%); + border-color: #b8d4ee; +} + +.hosts-file-filter-group--system { + background: linear-gradient(180deg, #f1f5f9 0%, #e8edf3 100%); + border-color: #cbd5e1; +} + +.hosts-file-filter-group--addn { + background: linear-gradient(180deg, #fff8e6 0%, #fff0cc 100%); + border-color: #e9d48a; +} + +.hosts-file-filter-group--hostsdir { + background: linear-gradient(180deg, #ecfdf3 0%, #dff6e8 100%); + border-color: #a7e3c0; +} + +.hosts-file-filter-group--other { + background: #f8fafc; +} + +.hosts-file-filter-group-header { + padding: 0.2rem 0.35rem 0.45rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #334155; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + margin-bottom: 0.15rem; } .hosts-toolbar-select { @@ -161,13 +366,31 @@ .hosts-group-header__path-row { display: flex; flex-wrap: wrap; - align-items: flex-end; + align-items: center; justify-content: space-between; gap: 0.5rem 0.75rem; width: 100%; min-width: 0; } +/* Fixed column for actions avoids path/subtitle reflow when buttons show or hide. */ +.hosts-group-header__path-row--with-managed-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 9.75rem); + align-items: center; + column-gap: 0.5rem; +} + +.hosts-group-header__path-row--with-managed-actions .hosts-group-header__path { + flex: unset; + min-width: 0; +} + +.hosts-group-header__path-row--with-managed-actions .hosts-group-header__managed-actions { + margin-left: 0; + justify-self: end; +} + .hosts-group-header__path { display: flex; flex-wrap: wrap; @@ -180,65 +403,60 @@ .hosts-group-header__managed-actions { flex: 0 0 auto; - align-self: flex-end; + align-self: center; margin-left: auto; display: inline-flex; align-items: center; - gap: 0.4rem; - min-height: 2.35rem; - padding: 0.25rem; + justify-content: flex-end; + gap: 0.28rem; + min-height: 1.65rem; + max-width: 100%; + padding: 0.18rem; border: 1px solid rgba(148, 163, 184, 0.26); border-radius: 999px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.94) 100%); - box-shadow: 0 12px 24px -20px rgba(15, 23, 42, 0.45); + box-shadow: 0 8px 18px -16px rgba(15, 23, 42, 0.4); backdrop-filter: blur(10px); - transform-origin: top right; - transition: opacity 180ms ease, transform 180ms ease, max-width 200ms ease, padding 180ms ease, border-color 180ms ease, box-shadow 180ms ease; + transition: opacity 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; overflow: hidden; } .hosts-group-header__managed-actions--visible { opacity: 1; - transform: translateY(0) scale(1); - max-width: 16rem; } .hosts-group-header__managed-actions--hidden { opacity: 0; - transform: translateY(-4px) scale(0.96); - max-width: 0; - padding-left: 0; - padding-right: 0; border-color: transparent; box-shadow: none; + background: transparent; pointer-events: none; } .hosts-managed-action-btn { display: inline-flex; align-items: center; - gap: 0.4rem; - min-height: 1.9rem; - padding: 0.38rem 0.78rem; + gap: 0.3rem; + min-height: 1.28rem; + padding: 0.22rem 0.52rem; border: 1px solid transparent; border-radius: 999px; - font-size: 0.84rem; + font-size: 0.75rem; font-weight: 600; letter-spacing: 0.01em; line-height: 1; box-shadow: none; - transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease, color 140ms ease; - animation: hosts-managed-action-enter 180ms ease; + transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease; } .hosts-managed-action-btn i { - font-size: 0.85rem; + font-size: 0.78rem; } .hosts-managed-action-btn:hover, .hosts-managed-action-btn:focus-visible { - transform: translateY(-1px); - box-shadow: 0 10px 18px -14px rgba(15, 23, 42, 0.55); + transform: translateY(-0.5px); + box-shadow: 0 6px 14px -12px rgba(15, 23, 42, 0.5); } .hosts-managed-action-btn:active { @@ -285,18 +503,6 @@ border-color: rgba(239, 68, 68, 0.42); } -@keyframes hosts-managed-action-enter { - from { - opacity: 0; - transform: translateY(-4px) scale(0.96); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - .hosts-group-header__badge { flex-shrink: 0; } @@ -382,14 +588,23 @@ } @media (max-width: 640.98px) { + .hosts-group-header__path-row--with-managed-actions { + --hosts-managed-actions-col: 9rem; + } + .hosts-group-header__managed-actions { - gap: 0.3rem; - min-height: 2.2rem; + gap: 0.22rem; + min-height: 1.55rem; + padding: 0.15rem; } .hosts-managed-action-btn { - padding: 0.34rem 0.68rem; - font-size: 0.8rem; + padding: 0.2rem 0.45rem; + font-size: 0.72rem; + } + + .hosts-managed-action-btn i { + font-size: 0.74rem; } } @@ -419,10 +634,16 @@ border-radius: 12px; } + .hosts-toolbar-fields { + flex-wrap: wrap; + } + .hosts-toolbar-search, + .hosts-file-filter-wrap, .hosts-toolbar-select { min-width: 0; max-width: none; + flex: 1 1 100%; width: 100%; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs index 806b6e4..785d547 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs @@ -25,6 +25,12 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService if (query.SourceKindFilter is { } sk) filtered = filtered.Where(r => r.SourceKind == sk); + if (!string.IsNullOrWhiteSpace(query.SourcePathFilter)) + { + var path = query.SourcePathFilter.Trim(); + filtered = filtered.Where(r => string.Equals(r.SourcePath, path, StringComparison.OrdinalIgnoreCase)); + } + if (query.EditableFilter is { } ed) filtered = filtered.Where(r => r.IsEditable == ed); diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterMenuModels.cs b/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterMenuModels.cs new file mode 100644 index 0000000..4e8b256 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterMenuModels.cs @@ -0,0 +1,10 @@ +namespace DnsmasqWebUI.Models.Hosts; + +/// One hosts file row in the filter menu (path, short name, non-comment line count). +public sealed record HostsFileFilterMenuItem(string Path, string FileName, int RecordCount); + +/// A source-kind section in the hosts file filter menu. +public sealed record HostsFileFilterMenuGroup( + HostsRowSourceKind SourceKind, + string Title, + IReadOnlyList Files); diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs b/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs index a78c13a..740a2a4 100644 --- a/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs +++ b/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs @@ -23,5 +23,7 @@ public sealed record HostsPageQueryState( HostsSortMode Sort, bool Descending, HostsRowSourceKind? SourceKindFilter, + /// When set, only rows whose matches (ordinal-ignore-case). + string? SourcePathFilter, bool? EditableFilter, bool? ActiveFilter); diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsSourcePathDisplay.cs b/src/DnsmasqWebUI/Models/Hosts/HostsSourcePathDisplay.cs new file mode 100644 index 0000000..2b90396 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsSourcePathDisplay.cs @@ -0,0 +1,81 @@ +namespace DnsmasqWebUI.Models.Hosts; + +/// Labels for hosts in UI (e.g. file filter dropdown). +public static class HostsSourcePathDisplay +{ + public static string FormatOptionLabel(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = Path.GetFileName(trimmed); + return string.IsNullOrEmpty(name) ? path : name; + } + + /// + /// Builds grouped menu data: paths grouped by with per-file record counts + /// (non-comment lines). Ensures appears even when there are no managed rows yet. + /// + public static IReadOnlyList BuildFileFilterMenuGroups( + IReadOnlyList rows, + string? managedHostsPathAlwaysShow) + { + var byPath = rows + .Where(r => !string.IsNullOrWhiteSpace(r.SourcePath)) + .GroupBy(r => r.SourcePath.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => ( + Path: g.Key, + Kind: g.First().SourceKind, + Count: g.Count(r => !r.IsComment))) + .ToList(); + + var managedTrim = managedHostsPathAlwaysShow?.Trim(); + if (!string.IsNullOrEmpty(managedTrim) + && !byPath.Any(e => string.Equals(e.Path, managedTrim, StringComparison.OrdinalIgnoreCase))) + byPath.Add((managedTrim, HostsRowSourceKind.Managed, 0)); + + return byPath + .GroupBy(e => e.Kind) + .OrderBy(g => SourceKindOrder(g.Key)) + .Select(g => + { + var files = g.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase) + .Select(e => new HostsFileFilterMenuItem(e.Path, FormatOptionLabel(e.Path), e.Count)) + .ToList(); + return new HostsFileFilterMenuGroup( + g.Key, + GroupMenuTitle(g.Key), + files); + }) + .ToList(); + } + + public static string GroupMenuTitle(HostsRowSourceKind kind) => kind switch + { + HostsRowSourceKind.Managed => "Managed hosts", + HostsRowSourceKind.SystemHosts => "System hosts", + HostsRowSourceKind.AddnHosts => "Additional hosts", + HostsRowSourceKind.Hostsdir => "Hosts directory", + _ => "Hosts files" + }; + + public static string GroupSummaryPrefix(HostsRowSourceKind kind) => kind switch + { + HostsRowSourceKind.Managed => "Managed", + HostsRowSourceKind.SystemHosts => "System", + HostsRowSourceKind.AddnHosts => "Additional", + HostsRowSourceKind.Hostsdir => "Hosts dir", + _ => "Hosts" + }; + + private static int SourceKindOrder(HostsRowSourceKind kind) => kind switch + { + HostsRowSourceKind.Managed => 0, + HostsRowSourceKind.SystemHosts => 1, + HostsRowSourceKind.AddnHosts => 2, + HostsRowSourceKind.Hostsdir => 3, + _ => 99 + }; + +}