From c2f869aeab642c1bf7e3c89016bf3d00533ae961 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 26 Sep 2026 18:28:50 +0200 Subject: [PATCH 01/11] Add hide-line flag and Core line visibility map/tracker (#338) HighlightEntry.IsHideLine (default false, cloned, serialized), HighlightEvaluator.IsHidden, an immutable row<->original-line map with append-only shared storage, and a tracker that rebuilds the map on a cancellable background scan with generation-tagged results. --- .../Classes/Highlight/HighlightEntry.cs | 7 + .../Classes/Highlight/HighlightEvaluator.cs | 12 + .../Classes/Highlight/LineVisibilityMap.cs | 234 ++++++++++++ .../Highlight/LineVisibilityTracker.cs | 349 ++++++++++++++++++ .../Highlight/HideLineRuleTests.cs | 113 ++++++ .../Highlight/LineVisibilityMapTests.cs | 142 +++++++ .../Highlight/LineVisibilityTrackerTests.cs | 285 ++++++++++++++ 7 files changed, 1142 insertions(+) create mode 100644 src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs create mode 100644 src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs create mode 100644 src/LogExpert.Tests/Highlight/HideLineRuleTests.cs create mode 100644 src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs create mode 100644 src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs diff --git a/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs b/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs index 976e772a..16fcbb29 100644 --- a/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs +++ b/src/LogExpert.Core/Classes/Highlight/HighlightEntry.cs @@ -97,6 +97,12 @@ public Regex Regex public bool NoBackground { get; set; } + /// + /// When true, lines matching this entry are removed from the Log Window's main grid. Display state only: + /// the lines stay in the reader and remain eligible for search, filtering and triggers. + /// + public bool IsHideLine { get; set; } + public object Clone () { var highLightEntry = new HighlightEntry @@ -118,6 +124,7 @@ public object Clone () CooldownSeconds = CooldownSeconds, BookmarkComment = BookmarkComment, NoBackground = NoBackground, + IsHideLine = IsHideLine, IsSearchHit = IsSearchHit }; diff --git a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs index 6f24ec38..f29de37a 100644 --- a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs +++ b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs @@ -59,6 +59,18 @@ public static IList FindMatchingEntries (IEnumerable + /// Returns whether any hide-line entry matches the line. Other matching entries cannot cancel the decision, + /// whatever their order. Temporary search-hit entries never hide. + /// + public static bool IsHidden (IEnumerable entries, ITextValueMemory line) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(line); + + return entries.Any(e => e.IsHideLine && !e.IsSearchHit && IsMatch(e, line)); + } + /// /// Classifies the non-plugin trigger actions implied by a set of matching entries: whether to /// suppress the dirty LED, stop tailing, set a bookmark, and the concatenated bookmark comment. diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs new file mode 100644 index 00000000..4c0af88f --- /dev/null +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs @@ -0,0 +1,234 @@ +namespace LogExpert.Core.Classes.Highlight; + +/// +/// Immutable mapping between the rows of a Log Window's main grid and the original logical lines of its +/// Logfile Reader, after highlight-based hiding. Memory is proportional to the number of hidden lines. +/// +/// shares storage with the map it extends, so appending tail lines is cheap. The storage is +/// append-only: a map only ever reads the prefix it was created with, so an older map stays valid. +/// +/// +public sealed class LineVisibilityMap +{ + private readonly HiddenLineStore _store; + + private LineVisibilityMap (HiddenLineStore store, int lineCount, int hiddenCount) + { + _store = store; + LineCount = lineCount; + HiddenCount = hiddenCount; + } + + /// A new map covering no lines. Not a shared instance, so unrelated appends never share storage. + public static LineVisibilityMap Empty => Identity(0); + + /// Total number of original logical lines covered by the map. + public int LineCount { get; } + + public int HiddenCount { get; } + + public int VisibleCount => LineCount - HiddenCount; + + /// A map in which every line is visible. + public static LineVisibilityMap Identity (int lineCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(lineCount); + return new LineVisibilityMap(new HiddenLineStore([], 0, lineCount), lineCount, 0); + } + + /// Returns the original line displayed in , or -1 when there is no such row. + public int RowToLine (int row) + { + if (row < 0 || row >= VisibleCount) + { + return -1; + } + + if (HiddenCount == 0) + { + return row; + } + + // hidden[i] - i is non-decreasing: count the hidden lines that lie before the row's line. + var items = _store.Items; + int low = 0, high = HiddenCount; + while (low < high) + { + var mid = (low + high) >>> 1; + if (items[mid] - mid <= row) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return row + low; + } + + /// Returns the row displaying , or -1 when the line is hidden or out of range. + public int LineToRow (int line) + { + if (line < 0 || line >= LineCount) + { + return -1; + } + + var index = Array.BinarySearch(_store.Items, 0, HiddenCount, line); + return index >= 0 ? -1 : line - ~index; + } + + public bool IsHidden (int line) + { + return line >= 0 && line < LineCount && Array.BinarySearch(_store.Items, 0, HiddenCount, line) >= 0; + } + + /// + /// Returns the row of when it is visible; otherwise the row of the next visible line, + /// falling back to the previous one. Returns -1 when no line is visible. + /// + public int NearestRow (int line) + { + if (VisibleCount == 0) + { + return -1; + } + + if (line >= LineCount) + { + return VisibleCount - 1; + } + + line = Math.Max(line, 0); + var index = Array.BinarySearch(_store.Items, 0, HiddenCount, line); + var hiddenBefore = index >= 0 ? index : ~index; + + // For a hidden line, line - hiddenBefore is the row of the next visible line, if there is one. + return Math.Min(line - hiddenBefore, VisibleCount - 1); + } + + /// + /// Returns a map covering lines, in which + /// (ascending, all within the appended range) are hidden in addition to this map's hidden lines. + /// + public LineVisibilityMap Append (int lineCount, IReadOnlyList hiddenLines) + { + ArgumentNullException.ThrowIfNull(hiddenLines); + ArgumentOutOfRangeException.ThrowIfLessThan(lineCount, LineCount); + + var previous = LineCount - 1; + foreach (var line in hiddenLines) + { + if (line <= previous || line >= lineCount) + { + throw new ArgumentOutOfRangeException(nameof(hiddenLines), line, "Hidden lines must be ascending and within the appended range."); + } + + previous = line; + } + + var store = _store.TryAppend(LineCount, HiddenCount, lineCount, hiddenLines) + ?? HiddenLineStore.CopyAppend(_store.Items, HiddenCount, lineCount, hiddenLines); + + return new LineVisibilityMap(store, lineCount, HiddenCount + hiddenLines.Count); + } + + /// Drops the first lines (rollover) and renumbers the rest. + public LineVisibilityMap Shift (int offset) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + + var items = _store.Items; + var first = Array.BinarySearch(items, 0, HiddenCount, offset); + first = first >= 0 ? first : ~first; + + var shifted = new int[HiddenCount - first]; + for (var i = 0; i < shifted.Length; i++) + { + shifted[i] = items[first + i] - offset; + } + + var lineCount = Math.Max(0, LineCount - offset); + return new LineVisibilityMap(new HiddenLineStore(shifted, shifted.Length, lineCount), lineCount, shifted.Length); + } + + /// Keeps only the first lines. + public LineVisibilityMap Truncate (int lineCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(lineCount); + + lineCount = Math.Min(lineCount, LineCount); + var kept = Array.BinarySearch(_store.Items, 0, HiddenCount, lineCount); + kept = kept >= 0 ? kept : ~kept; + + var items = _store.Items.AsSpan(0, kept).ToArray(); + return new LineVisibilityMap(new HiddenLineStore(items, kept, lineCount), lineCount, kept); + } + + /// + /// True when this map extends without changing any of its lines, so rows that + /// displays keep their row index. + /// + public bool IsAppendOf (LineVisibilityMap other) + { + ArgumentNullException.ThrowIfNull(other); + return ReferenceEquals(_store, other._store) && other.LineCount <= LineCount && other.HiddenCount <= HiddenCount; + } + + /// + /// Append-only storage of hidden line numbers shared by a chain of appended maps. It is only extended in place + /// by the map at its committed end; any other append copies, so no map ever observes a changed prefix. + /// + private sealed class HiddenLineStore (int[] items, int committedHidden, int committedLines) + { + private readonly Lock _lock = new(); + private int _committedHidden = committedHidden; + private int _committedLines = committedLines; + private volatile int[] _items = items; + + public int[] Items => _items; + + public HiddenLineStore? TryAppend (int fromLines, int fromHidden, int lineCount, IReadOnlyList hiddenLines) + { + lock (_lock) + { + if (_committedLines != fromLines || _committedHidden != fromHidden) + { + return null; + } + + var required = fromHidden + hiddenLines.Count; + var target = _items; + if (required > target.Length) + { + target = new int[Math.Max(required, Math.Max(16, target.Length * 2))]; + Array.Copy(_items, target, fromHidden); + } + + for (var i = 0; i < hiddenLines.Count; i++) + { + target[fromHidden + i] = hiddenLines[i]; + } + + _items = target; + _committedHidden = required; + _committedLines = lineCount; + return this; + } + } + + public static HiddenLineStore CopyAppend (int[] items, int hiddenCount, int lineCount, IReadOnlyList hiddenLines) + { + var copy = new int[hiddenCount + hiddenLines.Count]; + Array.Copy(items, copy, hiddenCount); + for (var i = 0; i < hiddenLines.Count; i++) + { + copy[hiddenCount + i] = hiddenLines[i]; + } + + return new HiddenLineStore(copy, copy.Length, lineCount); + } + } +} diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs new file mode 100644 index 00000000..4199c106 --- /dev/null +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs @@ -0,0 +1,349 @@ +using ColumnizerLib; + +namespace LogExpert.Core.Classes.Highlight; + +/// +/// Owns a Log Window's : which original lines the hide-line Highlight Entries of the +/// active Highlight Group remove from the main grid. +/// +/// Full scans (new content, changed rules) run on a cancellable background task against a cloned rule snapshot; the +/// previous map stays current until the scan's result replaces it in one step. Every structural change bumps a +/// generation, so a scan that finishes against outdated content or rules is discarded. Appended tail lines are +/// evaluated synchronously by the caller's thread. Evaluation uses only, +/// so it can never fire a trigger. +/// +/// +public sealed class LineVisibilityTracker : IDisposable +{ + private readonly Func _getLine; + private readonly Lock _lock = new(); + + private volatile LineVisibilityMap _map = LineVisibilityMap.Empty; + private HighlightEntry[] _rules = []; + private HighlightEntry[]? _pendingRules; + private CancellationTokenSource? _scanCts; + private Task _scanTask = Task.CompletedTask; + private int _generation; + private bool _disposed; + + /// Reads an original line; read live, since the reader is replaced on reload. + public LineVisibilityTracker (Func getLine) + { + ArgumentNullException.ThrowIfNull(getLine); + _getLine = getLine; + } + + /// + /// Raised on a worker thread when a background scan replaces the map, or when evaluation failed and every line + /// was made visible ( set). + /// + public event EventHandler? Changed; + + public LineVisibilityMap Map => _map; + + public bool IsScanning + { + get + { + lock (_lock) + { + return _pendingRules != null; + } + } + } + + /// New content was loaded: every line is visible until the scan with completes. + public void Load (int lineCount, IEnumerable entries) + { + var rules = Snapshot(entries); + lock (_lock) + { + if (_disposed) + { + return; + } + + _rules = []; + _map = LineVisibilityMap.Identity(lineCount); + StartOrStopScanLocked(rules); + } + } + + /// The Highlight Group or its entries changed. Unchanged hide rules keep the current map. + public void Rebuild (IEnumerable entries) + { + var rules = Snapshot(entries); + LineVisibilityMap? cleared = null; + lock (_lock) + { + if (_disposed || SameRules(_pendingRules ?? _rules, rules)) + { + return; + } + + StartOrStopScanLocked(rules); + if (rules.Length == 0 && _map.HiddenCount > 0) + { + _rules = []; + _map = cleared = LineVisibilityMap.Identity(_map.LineCount); + } + } + + if (cleared != null) + { + Changed?.Invoke(this, new LineVisibilityChangedEventArgs(cleared, null)); + } + } + + /// Tail path: evaluates lines appended up to and returns the current map. + public LineVisibilityMap Extend (int lineCount) + { + Exception? error; + LineVisibilityMap map; + lock (_lock) + { + if (_disposed || lineCount <= _map.LineCount) + { + return _map; + } + + (map, error) = EvaluateLocked(_map, lineCount); + } + + RaiseFailure(map, error); + return map; + } + + /// Tail path, rollover: the first lines were dropped. + public LineVisibilityMap Shift (int offset) + { + lock (_lock) + { + if (!_disposed) + { + _map = _map.Shift(offset); + RestartPendingScanLocked(); + } + + return _map; + } + } + + /// Tail path, truncation: the content was replaced and is re-evaluated up to . + public LineVisibilityMap Replace (int lineCount) + { + Exception? error; + LineVisibilityMap map; + lock (_lock) + { + if (_disposed) + { + return _map; + } + + (map, error) = EvaluateLocked(LineVisibilityMap.Empty, lineCount); + RestartPendingScanLocked(); + } + + RaiseFailure(map, error); + return map; + } + + /// Completes once no scan is running (including scans restarted meanwhile). + public async Task WhenIdle () + { + while (true) + { + Task task; + lock (_lock) + { + task = _scanTask; + } + + await task.ConfigureAwait(false); + + lock (_lock) + { + if (task == _scanTask) + { + return; + } + } + } + } + + public void Dispose () + { + lock (_lock) + { + if (_disposed) + { + return; + } + + _disposed = true; + _generation++; + _pendingRules = null; + _scanCts?.Cancel(); + _scanCts?.Dispose(); + _scanCts = null; + } + } + + private void StartOrStopScanLocked (HighlightEntry[] rules) + { + _generation++; + _scanCts?.Cancel(); + _scanCts?.Dispose(); + _scanCts = null; + _pendingRules = null; + + if (rules.Length == 0) + { + return; + } + + var cts = new CancellationTokenSource(); + var generation = _generation; + var lineCount = _map.LineCount; + _scanCts = cts; + _pendingRules = rules; + _scanTask = Task.Run(() => Scan(rules, lineCount, generation, cts.Token), CancellationToken.None); + } + + private void RestartPendingScanLocked () + { + if (_pendingRules != null) + { + StartOrStopScanLocked(_pendingRules); + } + } + + private void Scan (HighlightEntry[] rules, int lineCount, int generation, CancellationToken token) + { + LineVisibilityMap map; + Exception? error = null; + try + { + List hidden = []; + for (var i = 0; i < lineCount; i++) + { + token.ThrowIfCancellationRequested(); + if (IsHiddenLine(rules, i)) + { + hidden.Add(i); + } + } + + lock (_lock) + { + if (generation != _generation) + { + return; + } + + // Catch up with lines the tail appended while the scan ran. + map = Evaluate(LineVisibilityMap.Empty.Append(lineCount, hidden), rules, _map.LineCount); + _map = map; + _rules = rules; + _pendingRules = null; + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + lock (_lock) + { + if (generation != _generation) + { + return; + } + + _map = map = LineVisibilityMap.Identity(_map.LineCount); + _rules = []; + _pendingRules = null; + error = ex; + } + } + + Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error)); + } + + private (LineVisibilityMap Map, Exception? Error) EvaluateLocked (LineVisibilityMap from, int lineCount) + { + try + { + _map = Evaluate(from, _rules, lineCount); + return (_map, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Never publish a partial state: fall back to showing every line. + _rules = []; + _map = LineVisibilityMap.Identity(lineCount); + return (_map, ex); + } + } + + private LineVisibilityMap Evaluate (LineVisibilityMap from, HighlightEntry[] rules, int lineCount) + { + if (lineCount <= from.LineCount) + { + return from; + } + + List hidden = []; + if (rules.Length > 0) + { + for (var i = from.LineCount; i < lineCount; i++) + { + if (IsHiddenLine(rules, i)) + { + hidden.Add(i); + } + } + } + + return from.Append(lineCount, hidden); + } + + private bool IsHiddenLine (HighlightEntry[] rules, int lineNum) + { + // An unreadable line cannot be classified; it stays visible. + var line = _getLine(lineNum); + return line != null && HighlightEvaluator.IsHidden(rules, line); + } + + private void RaiseFailure (LineVisibilityMap map, Exception? error) + { + if (error != null) + { + Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error)); + } + } + + private static HighlightEntry[] Snapshot (IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + return [.. entries.Where(e => e.IsHideLine && !e.IsSearchHit).Select(e => (HighlightEntry)e.Clone())]; + } + + private static bool SameRules (HighlightEntry[] current, HighlightEntry[] next) + { + return current.Length == next.Length + && current.Zip(next).All(pair => string.Equals(pair.First.SearchText, pair.Second.SearchText, StringComparison.Ordinal) + && pair.First.IsRegex == pair.Second.IsRegex + && pair.First.IsCaseSensitive == pair.Second.IsCaseSensitive); + } +} + +public sealed class LineVisibilityChangedEventArgs (LineVisibilityMap map, Exception? error) : EventArgs +{ + public LineVisibilityMap Map { get; } = map; + + /// Set when evaluation failed; the map then shows every line. + public Exception? Error { get; } = error; +} diff --git a/src/LogExpert.Tests/Highlight/HideLineRuleTests.cs b/src/LogExpert.Tests/Highlight/HideLineRuleTests.cs new file mode 100644 index 00000000..94e99982 --- /dev/null +++ b/src/LogExpert.Tests/Highlight/HideLineRuleTests.cs @@ -0,0 +1,113 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Entities; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.Highlight; + +[TestFixture] +public class HideLineRuleTests +{ + private sealed class TestLine (string text) : ITextValueMemory + { + public ReadOnlyMemory Text => text.AsMemory(); + } + + private static ITextValueMemory Line (string text) => new TestLine(text); + + [Test] + public void IsHideLine_DefaultsToFalse () + { + Assert.That(new HighlightEntry().IsHideLine, Is.False); + } + + [Test] + public void Clone_PreservesIsHideLine () + { + var clone = (HighlightEntry)new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }.Clone(); + + Assert.That(clone.IsHideLine, Is.True); + } + + [Test] + public void Json_RoundTripsIsHideLine () + { + var groups = new List + { + new() { GroupName = "g", HighlightEntryList = [new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }] } + }; + + var restored = JsonConvert.DeserializeObject>(JsonConvert.SerializeObject(groups)); + + Assert.That(restored[0].HighlightEntryList[0].IsHideLine, Is.True); + } + + [Test] + public void Json_LegacyEntryWithoutTheFlag_ReadsAsNotHidden () + { + const string legacy = """[{"GroupName":"g","HighlightEntryList":[{"SearchText":"DEBUG","IsStopTail":false}]}]"""; + + var restored = JsonConvert.DeserializeObject>(legacy); + + Assert.That(restored[0].HighlightEntryList[0].IsHideLine, Is.False); + } + + [Test] + public void IsHidden_MatchingHideRule_HidesLine () + { + HighlightEntry[] entries = [new HighlightEntry { SearchText = "debug", IsHideLine = true }]; + + Assert.That(HighlightEvaluator.IsHidden(entries, Line("2026 DEBUG noise")), Is.True); + Assert.That(HighlightEvaluator.IsHidden(entries, Line("2026 INFO start")), Is.False); + } + + [Test] + public void IsHidden_OnlyColoringRulesMatch_LineStaysVisible () + { + HighlightEntry[] entries = [new HighlightEntry { SearchText = "ERROR", BackgroundColor = Color.Red }]; + + Assert.That(HighlightEvaluator.IsHidden(entries, Line("ERROR boom")), Is.False); + } + + [Test] + public void IsHidden_EarlierColoringRuleMatches_CannotCancelLaterHideRule () + { + HighlightEntry[] entries = + [ + new HighlightEntry { SearchText = "heartbeat", BackgroundColor = Color.Yellow }, + new HighlightEntry { SearchText = "heart", IsHideLine = true } + ]; + + Assert.That(HighlightEvaluator.IsHidden(entries, Line("heartbeat ok")), Is.True); + } + + [Test] + public void IsHidden_WordMatchHideRule_HidesTheWholeLine () + { + HighlightEntry[] entries = [new HighlightEntry { SearchText = "ping", IsWordMatch = true, IsHideLine = true }]; + + Assert.That(HighlightEvaluator.IsHidden(entries, Line("host ping 3ms")), Is.True); + } + + [Test] + public void IsHidden_RegexAndCaseSensitivity_UseHighlightSemantics () + { + HighlightEntry[] entries = [new HighlightEntry { SearchText = "^TRACE\\b", IsRegex = true, IsCaseSensitive = true, IsHideLine = true }]; + + Assert.That(HighlightEvaluator.IsHidden(entries, Line("TRACE enter")), Is.True); + Assert.That(HighlightEvaluator.IsHidden(entries, Line("trace enter")), Is.False); + Assert.That(HighlightEvaluator.IsHidden(entries, Line("x TRACE")), Is.False); + } + + [Test] + public void IsHidden_SearchHitEntries_NeverHide () + { + HighlightEntry[] entries = [new HighlightEntry { SearchText = "x", IsHideLine = true, IsSearchHit = true }]; + + Assert.That(HighlightEvaluator.IsHidden(entries, Line("x")), Is.False); + } +} diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs new file mode 100644 index 00000000..51d40b66 --- /dev/null +++ b/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs @@ -0,0 +1,142 @@ +using LogExpert.Core.Classes.Highlight; + +using NUnit.Framework; + +namespace LogExpert.Tests.Highlight; + +[TestFixture] +public class LineVisibilityMapTests +{ + private static LineVisibilityMap Map (int lineCount, params int[] hidden) + { + return LineVisibilityMap.Empty.Append(lineCount, hidden); + } + + [Test] + public void RowToLine_SkipsHiddenLines () + { + var map = Map(6, 1, 2, 4); + + Assert.That(map.VisibleCount, Is.EqualTo(3)); + Assert.That(map.HiddenCount, Is.EqualTo(3)); + Assert.That(Enumerable.Range(0, 3).Select(map.RowToLine), Is.EqualTo(new[] { 0, 3, 5 })); + } + + [Test] + public void LineToRow_VisibleLine_ReturnsItsRow_HiddenLine_ReturnsMinusOne () + { + var map = Map(6, 1, 2, 4); + + Assert.That(map.LineToRow(0), Is.EqualTo(0)); + Assert.That(map.LineToRow(3), Is.EqualTo(1)); + Assert.That(map.LineToRow(5), Is.EqualTo(2)); + Assert.That(map.LineToRow(1), Is.EqualTo(-1)); + Assert.That(map.LineToRow(4), Is.EqualTo(-1)); + } + + [TestCase(-1)] + [TestCase(6)] + public void OutOfRange_ReturnsMinusOne (int index) + { + var map = Map(6, 1); + + Assert.That(map.LineToRow(index), Is.EqualTo(-1)); + Assert.That(map.RowToLine(index), Is.EqualTo(-1)); + } + + [Test] + public void HiddenAtStartAndEnd_MapsMiddleOnly () + { + var map = Map(5, 0, 1, 4); + + Assert.That(map.VisibleCount, Is.EqualTo(2)); + Assert.That(map.RowToLine(0), Is.EqualTo(2)); + Assert.That(map.RowToLine(1), Is.EqualTo(3)); + Assert.That(map.RowToLine(2), Is.EqualTo(-1)); + } + + [Test] + public void AllLinesHidden_HasNoRows () + { + var map = Map(3, 0, 1, 2); + + Assert.That(map.VisibleCount, Is.Zero); + Assert.That(map.RowToLine(0), Is.EqualTo(-1)); + Assert.That(map.NearestRow(1), Is.EqualTo(-1)); + } + + [Test] + public void Identity_MapsEveryLineToItself () + { + var map = LineVisibilityMap.Identity(4); + + Assert.That(map.VisibleCount, Is.EqualTo(4)); + Assert.That(map.HiddenCount, Is.Zero); + Assert.That(map.RowToLine(3), Is.EqualTo(3)); + Assert.That(map.LineToRow(2), Is.EqualTo(2)); + } + + [Test] + public void NearestRow_HiddenLine_PrefersNextVisible_ThenPrevious () + { + var map = Map(8, 2, 3, 6, 7); + + Assert.That(map.NearestRow(4), Is.EqualTo(2), "visible line keeps its own row"); + Assert.That(map.NearestRow(2), Is.EqualTo(2), "hidden line → next visible line 4"); + Assert.That(map.NearestRow(6), Is.EqualTo(3), "no later visible line → previous visible line 5"); + Assert.That(map.NearestRow(100), Is.EqualTo(3), "beyond the end → last visible row"); + } + + [Test] + public void Append_ExtendsWithNewHiddenLines_AndKeepsOldMapUnchanged () + { + var before = Map(4, 1); + + var after = before.Append(7, [4, 6]); + + Assert.That(before.LineCount, Is.EqualTo(4)); + Assert.That(before.VisibleCount, Is.EqualTo(3)); + Assert.That(after.LineCount, Is.EqualTo(7)); + Assert.That(Enumerable.Range(0, after.VisibleCount).Select(after.RowToLine), Is.EqualTo(new[] { 0, 2, 3, 5 })); + Assert.That(after.IsAppendOf(before), Is.True); + } + + [Test] + public void Append_TwiceFromSameBase_DoesNotCorruptEitherBranch () + { + var baseMap = Map(2); + var first = baseMap.Append(4, [2]); + var second = baseMap.Append(4, [3]); + + Assert.That(first.IsHidden(2), Is.True); + Assert.That(first.IsHidden(3), Is.False); + Assert.That(second.IsHidden(2), Is.False); + Assert.That(second.IsHidden(3), Is.True); + } + + [Test] + public void Shift_DropsLeadingLines_AndRenumbersTheRest () + { + var map = Map(10, 1, 5, 8); + + var shifted = map.Shift(3); + + Assert.That(shifted.LineCount, Is.EqualTo(7)); + Assert.That(shifted.IsHidden(2), Is.True, "old line 5"); + Assert.That(shifted.IsHidden(5), Is.True, "old line 8"); + Assert.That(shifted.HiddenCount, Is.EqualTo(2)); + Assert.That(shifted.IsAppendOf(map), Is.False); + } + + [Test] + public void Truncate_DropsTrailingLines () + { + var map = Map(10, 1, 5, 8); + + var truncated = map.Truncate(6); + + Assert.That(truncated.LineCount, Is.EqualTo(6)); + Assert.That(truncated.HiddenCount, Is.EqualTo(2)); + Assert.That(truncated.VisibleCount, Is.EqualTo(4)); + } +} diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs new file mode 100644 index 00000000..83eb8b83 --- /dev/null +++ b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs @@ -0,0 +1,285 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; + +using NUnit.Framework; + +namespace LogExpert.Tests.Highlight; + +[TestFixture] +public class LineVisibilityTrackerTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + private static readonly HighlightEntry[] HideDebug = [new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }]; + + private List _lines = null!; + private ManualResetEventSlim _gate = null!; + private LineVisibilityTracker _tracker = null!; + private int _changedCount; + + [SetUp] + public void SetUp () + { + _lines = ["INFO a", "DEBUG b", "INFO c", "DEBUG d", "INFO e"]; + _gate = new ManualResetEventSlim(true); + _changedCount = 0; + _tracker = new LineVisibilityTracker(GetLine); + _tracker.Changed += (_, _) => Interlocked.Increment(ref _changedCount); + } + + [TearDown] + public void TearDown () + { + _gate.Set(); + _tracker.Dispose(); + _gate.Dispose(); + } + + private ITextValueMemory? GetLine (int lineNum) + { + _ = _gate.Wait(Timeout); + lock (_lines) + { + return lineNum < _lines.Count ? new LogLine(_lines[lineNum], lineNum) : null; + } + } + + private void Idle () + { + Assert.That(_tracker.WhenIdle().Wait(Timeout), Is.True, "scan did not finish"); + } + + private static int[] VisibleLines (LineVisibilityMap map) + { + return [.. Enumerable.Range(0, map.VisibleCount).Select(map.RowToLine)]; + } + + [Test] + public void Load_WithHideRule_PublishesMapHidingMatchingLines () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 })); + Assert.That(_tracker.Map.HiddenCount, Is.EqualTo(2)); + Assert.That(_changedCount, Is.EqualTo(1)); + } + + [Test] + public void Load_UntilScanCompletes_EveryLineStaysVisible () + { + _gate.Reset(); + + _tracker.Load(_lines.Count, HideDebug); + + Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5)); + Assert.That(_tracker.IsScanning, Is.True); + _gate.Set(); + Idle(); + Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(3)); + } + + [Test] + public void Load_WithoutHideRules_IsImmediatelyComplete () + { + _tracker.Load(_lines.Count, [new HighlightEntry { SearchText = "DEBUG" }]); + + Assert.That(_tracker.IsScanning, Is.False); + Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5)); + } + + [Test] + public void Extend_EvaluatesAppendedLines_AsAnAppendOfTheCurrentMap () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + var before = _tracker.Map; + _lines.AddRange(["DEBUG f", "INFO g"]); + + var after = _tracker.Extend(7); + + Assert.That(VisibleLines(after), Is.EqualTo(new[] { 0, 2, 4, 6 })); + Assert.That(after.IsAppendOf(before), Is.True); + Assert.That(_tracker.Map, Is.SameAs(after)); + } + + [Test] + public void Extend_ToAKnownLineCount_ChangesNothing () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + var before = _tracker.Map; + + Assert.That(_tracker.Extend(3), Is.SameAs(before)); + } + + [Test] + public void Extend_WhileScanPending_LinesAreCoveredByThePublishedMap () + { + _gate.Reset(); + _tracker.Load(_lines.Count, HideDebug); + _lines.Add("DEBUG f"); + + var interim = Task.Run(() => _tracker.Extend(6)); + _gate.Set(); + Assert.That(interim.Wait(Timeout), Is.True); + Idle(); + + Assert.That(_tracker.Map.LineCount, Is.EqualTo(6)); + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 })); + } + + [Test] + public void Rebuild_WhileScanPending_OnlyTheLatestRulesArePublished () + { + _gate.Reset(); + _tracker.Load(_lines.Count, HideDebug); + + _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]); + _gate.Set(); + Idle(); + + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 1, 3 })); + } + + [Test] + public void Rebuild_RemovingAllHideRules_ShowsEveryLine () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + + _tracker.Rebuild([]); + Idle(); + + Assert.That(_tracker.Map.HiddenCount, Is.Zero); + Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5)); + } + + [Test] + public void Rebuild_WithUnchangedHideRules_DoesNotRescan () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + var before = _tracker.Map; + + _tracker.Rebuild([new HighlightEntry { SearchText = "DEBUG", IsHideLine = true, BackgroundColor = Color.Red }]); + + Assert.That(_tracker.IsScanning, Is.False); + Assert.That(_tracker.Map, Is.SameAs(before)); + Assert.That(_changedCount, Is.EqualTo(1)); + } + + [Test] + public void Rebuild_SnapshotsRules_LaterEditsDoNotLeakIntoTheScan () + { + var rule = new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }; + _gate.Reset(); + _tracker.Load(_lines.Count, [rule]); + + rule.SearchText = "INFO"; + _gate.Set(); + Idle(); + + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 })); + } + + [Test] + public void Shift_RenumbersTheCurrentMap () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + + var shifted = _tracker.Shift(2); + + Assert.That(shifted.LineCount, Is.EqualTo(3)); + Assert.That(VisibleLines(shifted), Is.EqualTo(new[] { 0, 2 })); + } + + [Test] + public void Shift_WhileScanPending_RestartsTheScanOnTheNewNumbering () + { + _gate.Reset(); + _tracker.Load(_lines.Count, HideDebug); + lock (_lines) + { + _lines.RemoveRange(0, 2); + } + + _ = _tracker.Shift(2); + _gate.Set(); + Idle(); + + Assert.That(_tracker.Map.LineCount, Is.EqualTo(3)); + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2 })); + } + + [Test] + public void Replace_ReevaluatesEveryLineOfTheNewContent () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + lock (_lines) + { + _lines.Clear(); + _lines.AddRange(["DEBUG x", "INFO y"]); + } + + var replaced = _tracker.Replace(2); + + Assert.That(VisibleLines(replaced), Is.EqualTo(new[] { 1 })); + } + + [Test] + public void UnreadableLine_StaysVisible () + { + _tracker.Load(7, HideDebug); + Idle(); + + Assert.That(_tracker.Map.LineCount, Is.EqualTo(7)); + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4, 5, 6 })); + } + + [Test] + public void ScanFailure_PublishesAllVisible_AndReportsTheError () + { + LineVisibilityChangedEventArgs? reported = null; + using var tracker = new LineVisibilityTracker(i => i == 3 ? throw new InvalidOperationException("boom") : new LogLine("DEBUG", i)); + tracker.Changed += (_, e) => reported = e; + + tracker.Load(5, HideDebug); + Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True); + + Assert.That(tracker.Map.HiddenCount, Is.Zero); + Assert.That(tracker.Map.VisibleCount, Is.EqualTo(5)); + Assert.That(reported?.Error, Is.InstanceOf()); + } + + [Test] + public void TailFailure_FallsBackToAllVisible () + { + var fail = false; + using var tracker = new LineVisibilityTracker(i => fail ? throw new InvalidOperationException("boom") : new LogLine(i % 2 == 0 ? "DEBUG" : "INFO", i)); + tracker.Load(4, HideDebug); + Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True); + fail = true; + + var map = tracker.Extend(6); + + Assert.That(map.LineCount, Is.EqualTo(6)); + Assert.That(map.HiddenCount, Is.Zero); + } + + [Test] + public void Dispose_CancelsAPendingScan_WithoutPublishing () + { + _gate.Reset(); + _tracker.Load(_lines.Count, HideDebug); + + _tracker.Dispose(); + _gate.Set(); + + Assert.That(_tracker.WhenIdle().Wait(Timeout), Is.True); + Assert.That(_changedCount, Is.Zero); + } +} From 6c62deb513173073f588b55533632c4b0fa81f73 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 26 Sep 2026 18:53:45 +0200 Subject: [PATCH 02/11] Hide matching lines in the Log Window main grid (#338) The main grid now displays the rows of a LineVisibilityMap; reader, bookmark, timestamp, search, session and row-height code keeps using original logical lines. Explicit navigation to a hidden line turns on the per-window 'Show hidden lines' override; tail follow tracks the last visible row. A notice bar shows the hidden-line count and the override, and the Highlight Entry dialog gets a 'Hide line' checkbox. --- src/LogExpert.Resources/Resources.Designer.cs | 80 +++- src/LogExpert.Resources/Resources.de.resx | 12 + src/LogExpert.Resources/Resources.resx | 12 + src/LogExpert.Resources/Resources.zh-CN.resx | 12 + .../HighlightEntryDialogHideLineTests.cs | 40 ++ .../HiddenLinesWindowTests.cs | 443 ++++++++++++++++++ .../Controls/LogWindow/ColumnCache.cs | 87 +++- .../LogWindow/LogWindow.LineVisibility.cs | 312 ++++++++++++ .../Controls/LogWindow/LogWindow.cs | 361 +++++++------- .../HighlightEntryDialog.Designer.cs | 13 + .../Dialogs/Highlight/HighlightEntryDialog.cs | 3 + .../Interface/ILogPaintContextUI.cs | 2 +- 12 files changed, 1187 insertions(+), 190 deletions(-) create mode 100644 src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs create mode 100644 src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs create mode 100644 src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index a97e02a2..af6c275f 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -416,7 +416,7 @@ public static string Columnizer_SnapshotUnavailable { return ResourceManager.GetString("Columnizer_SnapshotUnavailable", resourceCulture); } } - + /// /// Looks up a localized string similar to Timestamp selector. /// @@ -1126,6 +1126,15 @@ public static string HighlightDialog_UI_CheckBox_DontDirtyLed { } } + /// + /// Looks up a localized string similar to Hide line. + /// + public static string HighlightDialog_UI_CheckBox_HideLine { + get { + return ResourceManager.GetString("HighlightDialog_UI_CheckBox_HideLine", resourceCulture); + } + } + /// /// Looks up a localized string similar to No Background. /// @@ -3267,6 +3276,15 @@ public static string LogWindow_UI_CheckBox_RangeSearch { } } + /// + /// Looks up a localized string similar to Show hidden lines. + /// + public static string LogWindow_UI_CheckBox_ShowHiddenLines { + get { + return ResourceManager.GetString("LogWindow_UI_CheckBox_ShowHiddenLines", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hides the filter list after loading a filter. /// @@ -3521,6 +3539,15 @@ public static string LogWindow_UI_Label_Fuzzyness { } } + /// + /// Looks up a localized string similar to {0:N0} lines hidden by highlight rules. + /// + public static string LogWindow_UI_Label_HiddenLines { + get { + return ResourceManager.GetString("LogWindow_UI_Label_HiddenLines", resourceCulture); + } + } + /// /// Looks up a localized string similar to Text &filter:. /// @@ -3576,6 +3603,15 @@ public static string LogWindow_UI_StatusLineError_FilterFailed { } } + /// + /// Looks up a localized string similar to Hide rules could not be applied, all lines are shown: {0}. + /// + public static string LogWindow_UI_StatusLineError_HideRulesFailed { + get { + return ResourceManager.GetString("LogWindow_UI_StatusLineError_HideRulesFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid regular expression. /// @@ -4163,7 +4199,7 @@ public static string MarkerBar_Bookmarks { return ResourceManager.GetString("MarkerBar_Bookmarks", resourceCulture); } } - + /// /// Looks up a localized string similar to Clear search. /// @@ -4172,7 +4208,7 @@ public static string MarkerBar_ClearSearch { return ResourceManager.GetString("MarkerBar_ClearSearch", resourceCulture); } } - + /// /// Looks up a localized string similar to Discovering markers…. /// @@ -4181,7 +4217,7 @@ public static string MarkerBar_Discovering { return ResourceManager.GetString("MarkerBar_Discovering", resourceCulture); } } - + /// /// Looks up a localized string similar to Filter hits. /// @@ -4190,7 +4226,7 @@ public static string MarkerBar_FilterHits { return ResourceManager.GetString("MarkerBar_FilterHits", resourceCulture); } } - + /// /// Looks up a localized string similar to Highlights. /// @@ -4199,7 +4235,7 @@ public static string MarkerBar_Highlights { return ResourceManager.GetString("MarkerBar_Highlights", resourceCulture); } } - + /// /// Looks up a localized string similar to Could not read log line {0} while discovering markers.. /// @@ -4208,7 +4244,7 @@ public static string MarkerBar_LineUnavailable { return ResourceManager.GetString("MarkerBar_LineUnavailable", resourceCulture); } } - + /// /// Looks up a localized string similar to Marker discovery failed: {0}. /// @@ -4217,7 +4253,7 @@ public static string MarkerBar_ScanFailed { return ResourceManager.GetString("MarkerBar_ScanFailed", resourceCulture); } } - + /// /// Looks up a localized string similar to Search hits. /// @@ -4226,7 +4262,7 @@ public static string MarkerBar_SearchHits { return ResourceManager.GetString("MarkerBar_SearchHits", resourceCulture); } } - + /// /// Looks up a localized string similar to Marker bar. /// @@ -4235,7 +4271,7 @@ public static string MarkerBar_Title { return ResourceManager.GetString("MarkerBar_Title", resourceCulture); } } - + /// /// Looks up a localized string similar to {0}: lines {1}–{2}, {3} matching lines. /// @@ -4244,7 +4280,7 @@ public static string MarkerBar_ToolTip { return ResourceManager.GetString("MarkerBar_ToolTip", resourceCulture); } } - + /// /// Looks up a localized string similar to Close existing tabs. /// @@ -4411,12 +4447,16 @@ public static string MultiFileMaskDialog_UI_Label_SettingsFor { /// Looks up a localized string similar to Pattern syntax: /// ///* = any characters (wildcard) - ///$D(&lt;date&gt;) = Date pattern + ///$D(<date>) = Date pattern ///$I = File index number ///$J = File index number, hidden when zero - ///$J(&lt;prefix&gt;) = Like $J, but adding &lt;prefix&gt; when non-zero + ///$J(<prefix>) = Like $J, but adding <prefix> when non-zero + /// + ///Examples: + ///*$J(.) → app.log, app.log.1, app.log.2 + ///*$J(.).log → app.log, app.1.log, app.2.log /// - ///&lt;date&gt;: + ///<date>: ///DD = day ///MM = month ///YY[YY] = year @@ -5588,7 +5628,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Show Error Message?. /// @@ -5606,7 +5646,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Show highlight markers. /// @@ -5615,7 +5655,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Show marker bar. /// @@ -5624,7 +5664,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar", resourceCulture); } } - + /// /// Looks up a localized string similar to Show search markers. /// @@ -5633,7 +5673,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Allow only 1 Instance. /// @@ -6552,7 +6592,7 @@ public static string SettingsDialog_UI_TabPage_tabPageMarkerBar { return ResourceManager.GetString("SettingsDialog_UI_TabPage_tabPageMarkerBar", resourceCulture); } } - + /// /// Looks up a localized string similar to Memory/CPU. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index e203113f..acf8879f 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -2352,4 +2352,16 @@ LogExpert neu starten, um die Änderungen zu übernehmen? Markierungsleiste + + Zeile ausblenden + + + {0:N0} Zeilen durch Hervorhebungsregeln ausgeblendet + + + Ausgeblendete Zeilen anzeigen + + + Ausblendregeln konnten nicht angewendet werden, alle Zeilen werden angezeigt: {0} + diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 6521310d..10f5d9a9 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2368,4 +2368,16 @@ Restart LogExpert to apply changes? {0}: lines {1}–{2}, {3} matching lines Marker discovery failed: {0} Marker bar + + Hide line + + + {0:N0} lines hidden by highlight rules + + + Show hidden lines + + + Hide rules could not be applied, all lines are shown: {0} + diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index c94a9515..d8127cc8 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -2246,4 +2246,16 @@ YY[YY] = 年 标记栏 + + 隐藏行 + + + {0:N0} 行已被高亮规则隐藏 + + + 显示隐藏的行 + + + 无法应用隐藏规则,显示所有行:{0} + \ No newline at end of file diff --git a/src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs b/src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs new file mode 100644 index 00000000..c146c31f --- /dev/null +++ b/src/LogExpert.UI.Tests/Dialogs/HighlightEntryDialogHideLineTests.cs @@ -0,0 +1,40 @@ +using System.Reflection; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.UI.Dialogs.Highlight; + +using NUnit.Framework; + +namespace LogExpert.UI.Tests.Dialogs; + +[TestFixture] +[Apartment(ApartmentState.STA)] +public class HighlightEntryDialogHideLineTests +{ + [TestCase(true)] + [TestCase(false)] + public void Load_ShowsTheEntrysHideFlag (bool hidden) + { + using var dialog = new HighlightEntryDialog(new HighlightEntry { SearchText = "DEBUG", IsHideLine = hidden }, [], false); + + Assert.That(HideCheckBox(dialog).Checked, Is.EqualTo(hidden)); + } + + [Test] + public void Ok_SavesTheHideFlag () + { + var entry = new HighlightEntry { SearchText = "DEBUG" }; + using var dialog = new HighlightEntryDialog(entry, [], false); + HideCheckBox(dialog).Checked = true; + + _ = typeof(HighlightEntryDialog).GetMethod("OnOkClick", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(dialog, [dialog, EventArgs.Empty]); + + Assert.That(entry.IsHideLine, Is.True); + } + + private static CheckBox HideCheckBox (Form dialog) + { + return (CheckBox)dialog.Controls.Find("_checkBoxHideLine", true).Single(); + } +} diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs new file mode 100644 index 00000000..dcd371d9 --- /dev/null +++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs @@ -0,0 +1,443 @@ +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Runtime.Versioning; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Config; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogTabWindow; +using LogExpert.UI.Controls.LogWindow; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.UI.Tests; + +/// Hide-line highlight rules (#338) in a real Log Window: odd lines are "DEBUG" and hidden. +[TestFixture] +[Apartment(ApartmentState.STA)] +[NonParallelizable] +[SupportedOSPlatform("windows")] +public sealed class HiddenLinesWindowTests : IDisposable +{ + private const int LINE_COUNT = 20; + + private string _directory = null!; + private string _fileName = null!; + private Settings _settings = null!; + private Mock _config = null!; + private LogTabWindow? _window; + private Exception? _uiException; + + private HighlightEntry HideRule => _settings.Preferences.HighlightGroupList[0].HighlightEntryList[0]; + + [SetUp] + public void SetUp () + { + _uiException = null; + _directory = Path.Join(Path.GetTempPath(), "LogExpertHiddenLinesTests", Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(_directory); + _fileName = Path.Join(_directory, "hidden.log"); + File.WriteAllLines(_fileName, Enumerable.Range(0, LINE_COUNT).Select(Text)); + _settings = new Settings(); + _settings.Preferences.MultiFileOptions = new MultiFileOptions(); + _settings.Preferences.FollowTail = false; + _settings.Preferences.AskForClose = false; + _settings.Preferences.AutoPick = false; + _settings.Preferences.OpenLastFiles = false; + _settings.Preferences.SaveSessions = false; + _settings.Preferences.SaveLocation = SessionSaveLocation.SameDir; + _settings.Preferences.HighlightGroupList = [new HighlightGroup + { + GroupName = "hide", + HighlightEntryList = + [ + new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }, + new HighlightEntry { SearchText = "INFO", BackgroundColor = Color.LightGreen } + ] + }]; + _config = new Mock(); + _ = _config.Setup(config => config.Settings).Returns(_settings); + _ = _config.Setup(config => config.ActiveConfigDir).Returns(_directory); + _ = _config.Setup(config => config.ActiveSessionDir).Returns(_directory); + _ = PluginRegistry.PluginRegistry.Create(_directory, 50); + Application.ThreadException += OnUiException; + } + + [TearDown] + public void TearDown () + { + _settings.Preferences.SaveSessions = false; + try + { + if (_window != null) + { + _window.LogExpertProxy = null; + _window.Close(); + _window.Dispose(); + _window = null; + } + } + finally + { + Application.ThreadException -= OnUiException; + } + + Directory.Delete(_directory, true); + } + + public void Dispose () + { + _window?.Dispose(); + } + + private static string Text (int line) => line % 2 == 1 ? $"DEBUG {line}" : $"INFO {line}"; + + [Test] + public void Load_HideRule_RemovesMatchingRows_AndKeepsOriginalLineNumbers () + { + var log = Open(); + var grid = Grid(log); + + Assert.That(grid.RowCount, Is.EqualTo(LINE_COUNT / 2)); + Assert.That(DisplayedLineNumber(grid, 0), Is.EqualTo("1")); + Assert.That(DisplayedLineNumber(grid, 1), Is.EqualTo("3")); + Assert.That(log.HiddenLineCount, Is.EqualTo(LINE_COUNT / 2)); + Assert.That(Find internal void MarkPrefetchStale () { + _sparseLines = null; _prefetchStartLine = -1; _prefetchCount = 0; _lastLineNumber = -1; @@ -117,14 +195,7 @@ internal IColumnizedLogLineMemory GetColumnsForLine (ILogfileReader logFileReade _lastColumnizer = columnizer; _lastLineNumber = lineNumber; - ILogLineMemory line = null; - - if (_prefetchedLines != null - && lineNumber >= _prefetchStartLine - && lineNumber < _prefetchStartLine + _prefetchCount) - { - line = _prefetchedLines[lineNumber - _prefetchStartLine]; - } + var line = GetPrefetchedLine(lineNumber); // Fallback: read directly. This is safe because the caller (CellValueNeeded) // has already called Prefetch/PrefetchFilterVisibleLines which pins the relevant diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs new file mode 100644 index 00000000..345df2d6 --- /dev/null +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs @@ -0,0 +1,312 @@ +using System.Globalization; + +using LogExpert.Core.Classes.Highlight; + +namespace LogExpert.UI.Controls.LogWindow; + +/// +/// Hide-line highlight rules (#338). The main grid shows the rows of ; everything that talks +/// to the reader, bookmarks, timestamps or sessions uses original logical lines, converted with +/// / . The Window Filter grid is unaffected. +/// +internal partial class LogWindow +{ + private const int COLUMN_FINDER_HEIGHT = 28; + private const int HIDDEN_LINES_BAR_HEIGHT = 24; + + private readonly Panel _hiddenLinesBar = new() { Name = "hiddenLinesBar", Dock = DockStyle.Top, Visible = false }; + private readonly Label _hiddenLinesLabel = new() { Name = "hiddenLinesLabel", AutoSize = true, Dock = DockStyle.Left, TextAlign = ContentAlignment.MiddleLeft }; + private readonly CheckBox _showHiddenLinesCheckBox = new() { Name = "showHiddenLinesCheckBox", AutoSize = true, Dock = DockStyle.Left }; + + private LineVisibilityTracker _lineVisibility; + + // Only touched on the UI thread; worker threads read it once into a local. + private volatile LineVisibilityMap _rowMap = LineVisibilityMap.Empty; + private LineVisibilityMap _appliedTrackedMap = LineVisibilityMap.Empty; + private bool _showHiddenLines; + private bool _isLoadComplete; + + /// Number of lines the active hide rules remove, whether or not the override shows them. + internal int HiddenLineCount => _lineVisibility.Map.HiddenCount; + + /// The per-window "Show hidden lines" override. Transient: not saved in the Session File. + internal bool ShowHiddenLines + { + get => _showHiddenLines; + set + { + if (_showHiddenLines == value) + { + return; + } + + _showHiddenLines = value; + ApplyLineVisibility(); + } + } + + internal Task WhenLineVisibilityIdle () + { + return _lineVisibility.WhenIdle(); + } + + private void InitializeLineVisibility () + { + _lineVisibility = new LineVisibilityTracker(line => _logFileReader?.GetLogLineMemory(line)); + _lineVisibility.Changed += OnLineVisibilityChanged; + + _showHiddenLinesCheckBox.Text = Resources.LogWindow_UI_CheckBox_ShowHiddenLines; + _showHiddenLinesCheckBox.CheckedChanged += (_, _) => ShowHiddenLines = _showHiddenLinesCheckBox.Checked; + _hiddenLinesBar.Padding = new Padding(4, 0, 4, 0); + _hiddenLinesBar.Controls.Add(_showHiddenLinesCheckBox); + _hiddenLinesBar.Controls.Add(_hiddenLinesLabel); + + // Row 0 hosts the column finder; the notice bar stacks above it. + tableLayoutPanel1.Controls.Remove(columnFinderPanel); + var topRow = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; + columnFinderPanel.Dock = DockStyle.Fill; + topRow.Controls.Add(columnFinderPanel); + topRow.Controls.Add(_hiddenLinesBar); + tableLayoutPanel1.Controls.Add(topRow, 0, 0); + } + + /// The content is gone (loading, dead file): no rows, no scan, until the next load publishes. + private void ResetLineVisibility () + { + _isLoadComplete = false; + _lineVisibility.Load(0, []); + _rowMap = LineVisibilityMap.Empty; + _appliedTrackedMap = _lineVisibility.Map; + UpdateHiddenLinesBar(); + } + + private void UpdateTopRowHeight () + { + var height = _guiStateArgs.ColumnFinderVisible ? COLUMN_FINDER_HEIGHT : 0; + if (_hiddenLinesBar.Visible) + { + _hiddenLinesBar.Height = LogicalToDeviceUnits(HIDDEN_LINES_BAR_HEIGHT); + height += _hiddenLinesBar.Height; + } + + columnFinderPanel.Visible = _guiStateArgs.ColumnFinderVisible; + tableLayoutPanel1.RowStyles[0].Height = height; + } + + private void UpdateHiddenLinesBar () + { + var hidden = _lineVisibility.Map.HiddenCount; + _hiddenLinesLabel.Text = string.Format(CultureInfo.CurrentCulture, Resources.LogWindow_UI_Label_HiddenLines, hidden); + _showHiddenLinesCheckBox.Checked = _showHiddenLines; + + var visible = hidden > 0 || _showHiddenLines; + if (_hiddenLinesBar.Visible != visible) + { + _hiddenLinesBar.Visible = visible; + UpdateTopRowHeight(); + } + } + + private List CurrentHighlightEntries () + { + lock (_currentHighlightGroupLock) + { + return [.. _currentHighlightGroup.HighlightEntryList]; + } + } + + private void RebuildLineVisibility () + { + if (!_isLoading && _logFileReader != null) + { + _lineVisibility.Rebuild(CurrentHighlightEntries()); + } + } + + private void OnLineVisibilityChanged (object sender, LineVisibilityChangedEventArgs e) + { + if (_isClosing || IsDisposed || Disposing) + { + return; + } + + try + { + // Marshals through a parent's handle when this (background) tab has none yet. + _ = BeginInvoke(() => + { + if (e.Error != null) + { + _logger.Warn(e.Error, "Hide rules failed"); + StatusLineError(string.Format(CultureInfo.CurrentCulture, Resources.LogWindow_UI_StatusLineError_HideRulesFailed, e.Error.Message)); + } + + ApplyLineVisibility(); + }); + } + catch (InvalidOperationException) + { + // No handle anywhere up the chain yet: OnHandleCreated applies the current map. + } + } + + protected override void OnHandleCreated (EventArgs e) + { + base.OnHandleCreated(e); + if (_lineVisibility != null && !ReferenceEquals(_appliedTrackedMap, _lineVisibility.Map)) + { + _ = BeginInvoke(ApplyLineVisibility); + } + } + + /// + /// The map the grid should display for : the tracked map itself, or, with the + /// override on, an identity map that keeps growing in place so tail appends stay appends. + /// + private LineVisibilityMap EffectiveMap (LineVisibilityMap tracked) + { + if (!_showHiddenLines) + { + return tracked; + } + + var current = _rowMap; + return current.HiddenCount == 0 && current.LineCount <= tracked.LineCount && tracked.IsAppendOf(_appliedTrackedMap) + ? current.Append(tracked.LineCount, []) + : LineVisibilityMap.Identity(tracked.LineCount); + } + + /// + /// Publishes the tracker's current map to the grid (UI thread). Row count and mapping change together; the + /// selected original line is kept, or moved to the next (else previous) visible line. + /// + private void ApplyLineVisibility () + { + if (_isLoading || _isClosing || _logFileReader == null || IsDisposed) + { + UpdateHiddenLinesBar(); + return; + } + + var tracked = _lineVisibility.Map; + var newMap = EffectiveMap(tracked); + _appliedTrackedMap = tracked; + + if (!ReferenceEquals(newMap, _rowMap)) + { + SetRowMap(newMap, 0); + if (_guiStateArgs.FollowTail && dataGridView.RowCount > 0) + { + _columnCache.MarkPrefetchStale(); + dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1; + } + + dataGridView.Invalidate(); + } + + UpdateHiddenLinesBar(); + UpdateLineNavigationReadiness(); + } + + /// + /// Switches the grid to . An append only grows the row count; any other change + /// rebuilds the rows and restores the selection and scroll position by original line, after moving those + /// lines up by . + /// + private void SetRowMap (LineVisibilityMap newMap, int rolloverOffset) + { + var oldMap = _rowMap; + if (newMap.IsAppendOf(oldMap) && rolloverOffset == 0) + { + _rowMap = newMap; + dataGridView.RowCount = newMap.VisibleCount; + return; + } + + var currentLine = oldMap.RowToLine(dataGridView.CurrentCellAddress.Y); + var firstLine = oldMap.RowToLine(dataGridView.FirstDisplayedScrollingRowIndex); + var hadCurrentLine = currentLine >= 0; + + dataGridView.RowCount = 0; + _columnCache.MarkPrefetchStale(); + _rowMap = newMap; + dataGridView.RowCount = newMap.VisibleCount; + if (_rowHeightList.Count > 0 && dataGridView.RowCount > 0) + { + dataGridView.UpdateRowHeightInfo(0, true); + } + + if (dataGridView.RowCount == 0) + { + return; + } + + if (firstLine >= 0) + { + var firstRow = newMap.NearestRow(Math.Max(0, firstLine - rolloverOffset)); + if (firstRow >= 0) + { + dataGridView.FirstDisplayedScrollingRowIndex = firstRow; + } + } + + if (hadCurrentLine && !_guiStateArgs.FollowTail) + { + var row = newMap.NearestRow(Math.Max(0, currentLine - rolloverOffset)); + if (row >= 0) + { + dataGridView.CurrentCell = dataGridView.Rows[row].Cells[0]; + dataGridView.Rows[row].Selected = true; + } + } + } + + /// Original line displayed in a main-grid row, or -1. + private int RowToLine (int row) + { + return _rowMap.RowToLine(row); + } + + /// Main-grid row of an original line, or -1 when it is hidden or out of range. + private int LineToRow (int line) + { + return _rowMap.LineToRow(line); + } + + /// Original line of the current main-grid row, or -1. + private int CurrentLogicalLine => RowToLine(dataGridView.CurrentCellAddress.Y); + + /// + /// Explicit navigation to an original line: a line beyond the end resolves to the last row; a hidden line turns + /// on "Show hidden lines" so that exact line can be selected. Returns the row, or -1. + /// + private int RevealLine (int line) + { + if (line < 0) + { + return -1; + } + + if (line >= _rowMap.LineCount) + { + return dataGridView.RowCount - 1; + } + + if (_rowMap.IsHidden(line)) + { + ShowHiddenLines = true; + } + + return LineToRow(line); + } + + /// Line navigation may run once the file is loaded and the first visibility scan is published. + private void UpdateLineNavigationReadiness () + { + if (_isLoadComplete && !_isReadyForLineNavigation && !_lineVisibility.IsScanning) + { + _isReadyForLineNavigation = true; + ApplyPendingLineNavigation(); + } + } +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index bd0afb94..0c7bf742 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -219,6 +219,7 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b tableLayoutPanel1.ColumnStyles[0].SizeType = SizeType.Percent; tableLayoutPanel1.ColumnStyles[0].Width = 100; InitializeMarkerBar(); + InitializeLineVisibility(); _logWindowCoordinator.HighlightSettingsChanged += OnParentHighlightSettingsChanged; SetColumnizer(_pluginRegistry.RegisteredColumnizers[0]); @@ -377,9 +378,10 @@ public bool ShowBookmarkBubbles } } + /// Original logical line of the current row, or -1. public int CurrentLineNum => dataGridView.CurrentRow == null ? -1 - : dataGridView.CurrentRow.Index; + : RowToLine(dataGridView.CurrentRow.Index); public string FileName { get; private set; } @@ -653,7 +655,7 @@ internal void ToggleColumnFinder (bool show, bool setFocus) _ = dataGridView.Focus(); } - tableLayoutPanel1.RowStyles[0].Height = show ? 28 : 0; + UpdateTopRowHeight(); } #endregion @@ -703,8 +705,7 @@ private void OnButtonSizeChanged (object sender, EventArgs e) [SupportedOSPlatform("windows")] internal void DumpBufferInfo () { - var currentLineNum = dataGridView.CurrentCellAddress.Y; - _logFileReader.LogBufferInfoForLine(currentLineNum); + _logFileReader.LogBufferInfoForLine(CurrentLogicalLine); } internal void DumpBufferDiagnostic () @@ -771,6 +772,7 @@ protected void OnFilterListChanged (LogWindow source) protected void OnCurrentHighlightListChanged () { InvalidateMarkerCriteria(MarkerScanSource.Highlights); + RebuildLineVisibility(); CurrentHighlightGroupChanged?.Invoke(this, new CurrentHighlightGroupChangedEventArgs(this, _currentHighlightGroup)); } @@ -821,6 +823,7 @@ private void OnLogWindowLoad (object sender, EventArgs e) private void OnLogWindowDisposed (object sender, EventArgs e) { DisposeMarkers(); + _lineVisibility.Dispose(); _waitingForClose = true; CancelPendingLineNavigation(); _logWindowCoordinator.HighlightSettingsChanged -= OnParentHighlightSettingsChanged; @@ -867,8 +870,8 @@ private void OnLogFileReaderFinishedLoading (object sender, EventArgs e) Invoke(() => { InvalidateMarkerCriteria(MarkerScanSource.All); - _isReadyForLineNavigation = true; - ApplyPendingLineNavigation(); + _isLoadComplete = true; + UpdateLineNavigationReadiness(); }); _ = _externaLoadingFinishedEvent.Set(); } @@ -958,7 +961,10 @@ private void OnDataGridViewCellValueNeeded (object sender, DataGridViewCellValue PrefetchVisibleLines(); var startCount = CurrentColumnizer?.GetColumnCount() ?? 0; - e.Value = GetCellValue(e.RowIndex, e.ColumnIndex); + var lineNum = RowToLine(e.RowIndex); + e.Value = lineNum < 0 + ? Column.EmptyColumn + : GetCellValue(lineNum, e.ColumnIndex); // The new column could be find dynamically. // Only support add new columns for now. @@ -983,10 +989,27 @@ private void PrefetchVisibleLines () var firstVisible = dataGridView.FirstDisplayedScrollingRowIndex; var visibleCount = dataGridView.DisplayedRowCount(includePartialRow: true); - if (firstVisible >= 0 && visibleCount > 0) + if (firstVisible < 0 || visibleCount <= 0) + { + return; + } + + var map = _rowMap; + if (map.HiddenCount == 0) { _columnCache.Prefetch(_logFileReader, firstVisible, visibleCount); + return; + } + + // Visible rows may be far apart in the file: pin only the lines they display. + var endRow = Math.Min(firstVisible + visibleCount, map.VisibleCount); + var lines = new int[Math.Max(0, endRow - firstVisible)]; + for (var i = 0; i < lines.Length; i++) + { + lines[i] = map.RowToLine(firstVisible + i); } + + _columnCache.PrefetchLines(_logFileReader, lines); } private void PrefetchFilterVisibleLines () @@ -1040,10 +1063,16 @@ private void OnDataGridViewCellValuePushed (object sender, DataGridViewCellValue return; } - var line = _logFileReader.GetLogLineMemory(e.RowIndex); + var lineNum = RowToLine(e.RowIndex); + if (lineNum < 0) + { + return; + } + + var line = _logFileReader.GetLogLineMemory(lineNum); var offset = CurrentColumnizer.GetTimeOffset(); CurrentColumnizer.SetTimeOffset(0); - ColumnizerCallbackObject.SetLineNum(e.RowIndex); + ColumnizerCallbackObject.SetLineNum(lineNum); var cols = CurrentColumnizer.SplitLine(ColumnizerCallbackObject, line); CurrentColumnizer.SetTimeOffset(offset); if (cols.ColumnValues.Length <= e.ColumnIndex - 2) @@ -1081,7 +1110,7 @@ private void OnDataGridViewCurrentCellChanged (object sender, EventArgs e) { if (dataGridView.CurrentRow != null) { - _statusEventArgs.CurrentLineNum = dataGridView.CurrentRow.Index + 1; + _statusEventArgs.CurrentLineNum = RowToLine(dataGridView.CurrentRow.Index) + 1; SendStatusLineUpdate(); if (syncFilterCheckBox.Checked) { @@ -1469,7 +1498,7 @@ private void OnDataGridContextMenuStripOpening (object sender, CancelEventArgs e var lineNum = -1; if (dataGridView.CurrentRow != null) { - lineNum = dataGridView.CurrentRow.Index; + lineNum = RowToLine(dataGridView.CurrentRow.Index); } if (lineNum == -1) @@ -1636,8 +1665,8 @@ private void OnScrollAllTabsToTimestampToolStripMenuItemClick (object sender, Ev { if (CurrentColumnizer.IsTimeshiftImplemented()) { - var currentLine = dataGridView.CurrentCellAddress.Y; - if (currentLine > 0 && currentLine < dataGridView.RowCount) + var currentLine = CurrentLogicalLine; + if (currentLine > 0) { var (timeStamp, _) = GetTimestampForLine(currentLine, false); if (timeStamp.Equals(DateTime.MinValue)) // means: invalid @@ -1655,7 +1684,7 @@ private void OnLocateLineInOriginalFileToolStripMenuItemClick (object sender, Ev { if (dataGridView.CurrentRow != null && FilterPipe != null) { - var lineNum = FilterPipe.GetOriginalLineNum(dataGridView.CurrentRow.Index); + var lineNum = FilterPipe.GetOriginalLineNum(RowToLine(dataGridView.CurrentRow.Index)); if (lineNum != -1) { FilterPipe.OriginWindow.SelectLine(lineNum, false, true); @@ -1725,7 +1754,7 @@ private void OnDataGridViewCellContextMenuStripNeeded (object sender, DataGridVi { if (e.RowIndex >= 0 && e.RowIndex < dataGridView.RowCount && !dataGridView.Rows[e.RowIndex].Selected) { - SelectLine(e.RowIndex, false, true); + SelectRow(e.RowIndex, false, true); } else if (e.RowIndex < 0) { @@ -2642,23 +2671,25 @@ private void LoadPersistenceData () _rowHeightList = snapshot.RowHeightList; try { - if (snapshot.CurrentLine >= 0 && snapshot.CurrentLine < dataGridView.RowCount) + // Saved positions are original lines; a hidden one resolves to the nearest visible line. + if (snapshot.CurrentLine >= 0 && snapshot.CurrentLine < _logFileReader.LineCount) { - SelectLine(snapshot.CurrentLine, false, true); + SelectNearestLine(snapshot.CurrentLine, false, true); } else { - if (_logFileReader.LineCount > 0) + if (dataGridView.RowCount > 0) { - dataGridView.FirstDisplayedScrollingRowIndex = _logFileReader.LineCount - 1; - SelectLine(_logFileReader.LineCount - 1, false, true); + dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1; + SelectRow(dataGridView.RowCount - 1, false, true); } } if (snapshot.FirstDisplayedLine >= 0 && - snapshot.FirstDisplayedLine < dataGridView.RowCount) + snapshot.FirstDisplayedLine < _logFileReader.LineCount && + _rowMap.NearestRow(snapshot.FirstDisplayedLine) >= 0) { - dataGridView.FirstDisplayedScrollingRowIndex = snapshot.FirstDisplayedLine; + dataGridView.FirstDisplayedScrollingRowIndex = _rowMap.NearestRow(snapshot.FirstDisplayedLine); } // Applied once, here (was double-applied: always pre-load, here only when true). @@ -2787,19 +2818,27 @@ private void EnterLoadFileStatus () ClearBookmarkList(); dataGridView.ClearSelection(); dataGridView.RowCount = 0; + ResetLineVisibility(); } [SupportedOSPlatform("windows")] private void PositionAfterReload (ReloadMemento reloadMemento) { - if (_reloadMemento.CurrentLine < dataGridView.RowCount && _reloadMemento.CurrentLine >= 0) + // The memento holds original lines; hidden ones resolve to the nearest visible row. + var currentRow = _reloadMemento.CurrentLine >= 0 && _reloadMemento.CurrentLine < _rowMap.LineCount + ? _rowMap.NearestRow(_reloadMemento.CurrentLine) + : -1; + if (currentRow >= 0) { - dataGridView.CurrentCell = dataGridView.Rows[_reloadMemento.CurrentLine].Cells[0]; + dataGridView.CurrentCell = dataGridView.Rows[currentRow].Cells[0]; } - if (_reloadMemento.FirstDisplayedLine < dataGridView.RowCount && _reloadMemento.FirstDisplayedLine >= 0) + var firstRow = _reloadMemento.FirstDisplayedLine >= 0 && _reloadMemento.FirstDisplayedLine < _rowMap.LineCount + ? _rowMap.NearestRow(_reloadMemento.FirstDisplayedLine) + : -1; + if (firstRow >= 0) { - dataGridView.FirstDisplayedScrollingRowIndex = _reloadMemento.FirstDisplayedLine; + dataGridView.FirstDisplayedScrollingRowIndex = firstRow; } } @@ -2817,6 +2856,7 @@ private void LogfileDead () dataGridView.Enabled = false; dataGridView.RowCount = 0; + ResetLineVisibility(); _progressEventArgs.Visible = false; _progressEventArgs.Value = _progressEventArgs.MaxValue; SendProgressBarUpdate(); @@ -3018,7 +3058,8 @@ private void LoadingFinished () _logFileReader.FileSizeChanged += OnFileSizeChanged; _isLoading = false; dataGridView.SuspendLayout(); - dataGridView.RowCount = _logFileReader.LineCount; + _lineVisibility.Load(_logFileReader.LineCount, CurrentHighlightEntries()); + ApplyLineVisibility(); dataGridView.CurrentCellChanged += OnDataGridViewCurrentCellChanged; dataGridView.Enabled = true; dataGridView.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.DisplayedCells); @@ -3054,12 +3095,17 @@ void ITailFollowSink.OnRolloverShift (int rolloverOffset) ShiftBookmarks(rolloverOffset); ShiftRowHeightList(rolloverOffset); ShiftFilterPipes(rolloverOffset); + _ = _lineVisibility.Shift(rolloverOffset); } void ITailFollowSink.OnTailLines (LogEventArgs e) { try { + // Evaluate hide rules for the new lines here, off the UI thread, before the grid shows them. + _ = !e.IsRollover && e.LineCount < e.PrevLineCount + ? _lineVisibility.Replace(e.LineCount) + : _lineVisibility.Extend(e.LineCount); _ = BeginInvoke(UpdateGrid, [e]); CheckFilterAndHighlight(e); } @@ -3105,63 +3151,21 @@ private void OnFileSizeChanged (LogEventArgs logEventArgs) private void UpdateGrid (LogEventArgs logEventArgs) { var oldRowCount = dataGridView.RowCount; - var firstDisplayedLine = dataGridView.FirstDisplayedScrollingRowIndex; - - if (dataGridView.CurrentCellAddress.Y >= logEventArgs.LineCount) - { - //this.dataGridView.Rows[this.dataGridView.CurrentCellAddress.Y].Selected = false; - //this.dataGridView.CurrentCell = this.dataGridView.Rows[0].Cells[0]; - } try { - if (dataGridView.RowCount > logEventArgs.LineCount) + // The engine thread already applied this event to the tracker (OnRolloverShift / OnTailLines). An append + // only grows the rows; a rollover or truncation rebuilds them and, without follow-tail, keeps the + // selected and first displayed original lines (moved up by the rollover offset). + var tracked = _lineVisibility.Map; + var newMap = EffectiveMap(tracked); + _appliedTrackedMap = tracked; + if (!ReferenceEquals(newMap, _rowMap)) { - var currentLineNum = dataGridView.CurrentCellAddress.Y; - dataGridView.RowCount = 0; - dataGridView.RowCount = logEventArgs.LineCount; - if (!_guiStateArgs.FollowTail) - { - if (currentLineNum >= dataGridView.RowCount) - { - currentLineNum = dataGridView.RowCount - 1; - } - - dataGridView.CurrentCell = dataGridView.Rows[currentLineNum].Cells[0]; - } - } - else - { - dataGridView.RowCount = logEventArgs.LineCount; + SetRowMap(newMap, logEventArgs.IsRollover ? logEventArgs.RolloverOffset : 0); } - //_logger.Debug($"UpdateGrid(): new RowCount={dataGridView.RowCount}"); - - if (logEventArgs.IsRollover) - { - // Multifile rollover - // keep selection and view range, if no follow tail mode - if (!_guiStateArgs.FollowTail) - { - var currentLineNum = dataGridView.CurrentCellAddress.Y; - currentLineNum -= logEventArgs.RolloverOffset; - if (currentLineNum < 0) - { - currentLineNum = 0; - } - - //_logger.Debug($"UpdateGrid(): Rollover=true, Rollover offset={logEventArgs.RolloverOffset}, currLineNum was {dataGridView.CurrentCellAddress.Y}, new currLineNum={currentLineNum}"); - firstDisplayedLine -= logEventArgs.RolloverOffset; - if (firstDisplayedLine < 0) - { - firstDisplayedLine = 0; - } - - dataGridView.FirstDisplayedScrollingRowIndex = firstDisplayedLine; - dataGridView.CurrentCell = dataGridView.Rows[currentLineNum].Cells[0]; - dataGridView.Rows[currentLineNum].Selected = true; - } - } + UpdateHiddenLinesBar(); _statusEventArgs.LineCount = logEventArgs.LineCount; StatusLineFileSize(logEventArgs.FileSize); @@ -3263,7 +3267,8 @@ private void CheckFilterAndHighlight (LogEventArgs e) if (firstStopTail) { var capturedLineNum = i; - _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); + // Not explicit navigation: a hidden trigger line is not revealed. + _ = BeginInvoke(() => SelectAndEnsureVisibleRow(_rowMap.NearestRow(capturedLineNum), false)); firstStopTail = false; } } @@ -3421,7 +3426,7 @@ private void SetColumnizerInternal (ILogLineMemoryColumnizer columnizer) if (_logFileReader != null) { - dataGridView.RowCount = _logFileReader.LineCount; + dataGridView.RowCount = _rowMap.VisibleCount; } if (_filterResultList != null) @@ -3917,7 +3922,7 @@ private void SyncTimestampDisplay () { if (dataGridView.CurrentRow != null) { - SyncTimestampDisplay(dataGridView.CurrentRow.Index); + SyncTimestampDisplay(RowToLine(dataGridView.CurrentRow.Index)); } } } @@ -3958,7 +3963,7 @@ private void SyncTimestampDisplayWorker () // timeout with no new Trigger -> update display var lineNum = _timeShiftSyncLine; - if (lineNum >= 0 && lineNum < dataGridView.RowCount) + if (lineNum >= 0 && lineNum < _rowMap.LineCount) { var (timeStamp, lineNumber) = GetTimestampForLine(lineNum, true); lineNum = lineNumber; @@ -3978,8 +3983,8 @@ private void SyncTimestampDisplayWorker () // show time difference between 2 selected lines if (dataGridView.SelectedRows.Count == 2) { - var row1 = dataGridView.SelectedRows[0].Index; - var row2 = dataGridView.SelectedRows[1].Index; + var row1 = RowToLine(dataGridView.SelectedRows[0].Index); + var row2 = RowToLine(dataGridView.SelectedRows[1].Index); if (row1 > row2) { (row2, row1) = (row1, row2); @@ -4011,7 +4016,7 @@ private void SyncFilterGridPos () { if (_filterResultList.Count > 0) { - var index = _filterResultList.BinarySearch(dataGridView.CurrentRow.Index); + var index = _filterResultList.BinarySearch(RowToLine(dataGridView.CurrentRow.Index)); if (index < 0) { index = ~index; @@ -4084,29 +4089,48 @@ private void ResetProgressBar () SendProgressBarUpdate(); } + /// + /// Explicit navigation to an original line (Go to Line, bookmarks, Log Search, filter results, time sync, …). + /// A hidden target turns on "Show hidden lines" so that exact line is selected. + /// [SupportedOSPlatform("windows")] private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll) + { + _shouldCallTimeSync = triggerSyncCall; + SelectRow(RevealLine(lineNum), triggerSyncCall, shouldScroll); + } + + /// Selects an original line, or the next (else previous) visible line when it is hidden. + [SupportedOSPlatform("windows")] + private void SelectNearestLine (int lineNum, bool triggerSyncCall, bool shouldScroll) + { + _shouldCallTimeSync = triggerSyncCall; + SelectRow(lineNum < 0 ? -1 : _rowMap.NearestRow(lineNum), triggerSyncCall, shouldScroll); + } + + [SupportedOSPlatform("windows")] + private void SelectRow (int rowNum, bool triggerSyncCall, bool shouldScroll) { try { _shouldCallTimeSync = triggerSyncCall; - if (lineNum < 0) + if (rowNum < 0) { return; } // Prevent ArgumentOutOfRangeException - if (lineNum >= dataGridView.Rows.GetRowCount(DataGridViewElementStates.None)) + if (rowNum >= dataGridView.Rows.GetRowCount(DataGridViewElementStates.None)) { - lineNum = dataGridView.Rows.GetRowCount(DataGridViewElementStates.None) - 1; + rowNum = dataGridView.Rows.GetRowCount(DataGridViewElementStates.None) - 1; } - dataGridView.Rows[lineNum].Selected = true; + dataGridView.Rows[rowNum].Selected = true; if (shouldScroll) { - dataGridView.CurrentCell = dataGridView.Rows[lineNum].Cells[0]; + dataGridView.CurrentCell = dataGridView.Rows[rowNum].Cells[0]; _ = dataGridView.Focus(); } } @@ -4161,11 +4185,11 @@ private void UpdateEditColumnDisplay (DataGridViewTextBoxEditingControl editCont [SupportedOSPlatform("windows")] private void SelectPrevHighlightLine () { - var lineNum = dataGridView.CurrentCellAddress.Y; + var lineNum = CurrentLogicalLine; while (lineNum > 0) { lineNum--; - var line = _logFileReader.GetLogLineMemory(lineNum); + var line = _rowMap.IsHidden(lineNum) ? null : _logFileReader.GetLogLineMemory(lineNum); if (line != null) { var entry = FindHighlightEntry(line); @@ -4181,11 +4205,11 @@ private void SelectPrevHighlightLine () [SupportedOSPlatform("windows")] private void SelectNextHighlightLine () { - var lineNum = dataGridView.CurrentCellAddress.Y; + var lineNum = CurrentLogicalLine; while (lineNum < _logFileReader.LineCount) { lineNum++; - var line = _logFileReader.GetLogLineMemory(lineNum); + var line = _rowMap.IsHidden(lineNum) ? null : _logFileReader.GetLogLineMemory(lineNum); if (line != null) { var entry = FindHighlightEntry(line); @@ -4201,7 +4225,7 @@ private void SelectNextHighlightLine () [SupportedOSPlatform("windows")] private int FindNextBookmarkIndex (int lineNum) { - if (lineNum >= dataGridView.RowCount) + if (lineNum >= _rowMap.LineCount) { lineNum = 0; } @@ -4218,7 +4242,7 @@ private int FindPrevBookmarkIndex (int lineNum) { if (lineNum <= 0) { - lineNum = dataGridView.RowCount - 1; + lineNum = _rowMap.LineCount - 1; } else { @@ -4277,7 +4301,7 @@ private void LoadFilterPipes () if (_filterPipeList.Count > 0) { - for (var i = 0; i < dataGridView.RowCount; ++i) + for (var i = 0; i < _logFileReader.LineCount; ++i) { ProcessFilterPipes(i); } @@ -4418,7 +4442,7 @@ private async void FilterSearch (string text) ClearFilterList(); _progressEventArgs.MinValue = 0; - _progressEventArgs.MaxValue = dataGridView.RowCount; + _progressEventArgs.MaxValue = _logFileReader.LineCount; _progressEventArgs.Value = 0; _progressEventArgs.Visible = true; SendProgressBarUpdate(); @@ -4826,7 +4850,7 @@ private void DisplayCurrentFileOnStatusline () { if (dataGridView.CurrentRow != null && dataGridView.CurrentRow.Index > -1) { - var fileName = _logFileReader.GetLogFileNameForLine(dataGridView.CurrentRow.Index); + var fileName = _logFileReader.GetLogFileNameForLine(RowToLine(dataGridView.CurrentRow.Index)); if (fileName != null) { StatusLineText(Util.GetNameFromPath(fileName)); @@ -5181,7 +5205,7 @@ private void CopyMarkedLinesToClipboard () { if (row.Index != -1) { - lineNumList.Add(row.Index); + lineNumList.Add(RowToLine(row.Index)); } } @@ -5269,7 +5293,7 @@ private List GetSelectedContent () { if (row.Index != -1) { - lineNumList.Add(row.Index); + lineNumList.Add(RowToLine(row.Index)); } } @@ -5293,7 +5317,7 @@ private void SetTimestampLimits () } (_guiStateArgs.MinTimestamp, _) = _timestampLocator.FindForward(0, _logFileReader.LineCount, true); - (_guiStateArgs.MaxTimestamp, _) = GetTimestampForLine(dataGridView.RowCount - 1, true); + (_guiStateArgs.MaxTimestamp, _) = GetTimestampForLine(_logFileReader.LineCount - 1, true); SendGuiStateUpdate(); } @@ -5398,9 +5422,11 @@ private void ChangeRowHeight (bool decrease) return; } + var lineNum = RowToLine(rowNum); + if (decrease) { - if (!_rowHeightList.TryGetValue(rowNum, out var entry)) + if (!_rowHeightList.TryGetValue(lineNum, out var entry)) { return; } @@ -5409,22 +5435,22 @@ private void ChangeRowHeight (bool decrease) entry.Height -= _lineHeight; if (entry.Height <= _lineHeight) { - _ = _rowHeightList.Remove(rowNum); + _ = _rowHeightList.Remove(lineNum); } } } else { RowHeightEntry entry; - if (!_rowHeightList.TryGetValue(rowNum, out var value)) + if (!_rowHeightList.TryGetValue(lineNum, out var value)) { entry = new RowHeightEntry { - LineNum = rowNum, + LineNum = lineNum, Height = _lineHeight }; - _rowHeightList[rowNum] = entry; + _rowHeightList[lineNum] = entry; } else { @@ -5445,7 +5471,8 @@ private void ChangeRowHeight (bool decrease) private int GetRowHeight (int rowNum) { - return _rowHeightList.TryGetValue(rowNum, out var value) + // _rowHeightList is keyed by original line (it is saved in the Session File). + return _rowHeightList.TryGetValue(RowToLine(rowNum), out var value) ? value.Height : _lineHeight; } @@ -5461,7 +5488,7 @@ private void AddBookmarkAtLineSilently (int lineNum) [SupportedOSPlatform("windows")] private void AddBookmarkAndEditComment () { - var lineNum = dataGridView.CurrentCellAddress.Y; + var lineNum = CurrentLogicalLine; if (!_bookmarkProvider.IsBookmarkAtLine(lineNum)) { ToggleBookmark(); @@ -5473,7 +5500,7 @@ private void AddBookmarkAndEditComment () [SupportedOSPlatform("windows")] private void AddBookmarkComment (string text) { - var lineNum = dataGridView.CurrentCellAddress.Y; + var lineNum = CurrentLogicalLine; Bookmark bookmark; if (!_bookmarkProvider.IsBookmarkAtLine(lineNum)) @@ -5497,14 +5524,18 @@ private void MarkCurrentFilterRange () _filterParams.RangeSearchText = filterRangeComboBox.Text; ColumnizerCallback callback = new(this); RangeFinder rangeFinder = new(_filterParams, callback); - var range = rangeFinder.FindRange(dataGridView.CurrentCellAddress.Y); + var range = rangeFinder.FindRange(CurrentLogicalLine); if (range != null) { SetCellSelectionMode(false); _noSelectionUpdates = true; for (var i = range.StartLine; i <= range.EndLine; ++i) { - dataGridView.Rows[i].Selected = true; + var row = LineToRow(i); + if (row >= 0) + { + dataGridView.Rows[row].Selected = true; + } } _noSelectionUpdates = false; @@ -5600,7 +5631,7 @@ private void AddSlaveToTimesync (LogWindow slave) TimeSyncList = slave.TimeSyncList; } - var currentLineNum = dataGridView.CurrentCellAddress.Y; + var currentLineNum = CurrentLogicalLine; var (timeStamp, _) = GetTimestampForLine(currentLineNum, true); if (!timeStamp.Equals(DateTime.MinValue) && !_shouldTimestampDisplaySyncingCancel) { @@ -5937,8 +5968,8 @@ public SessionSnapshot GatherSessionSnapshot () MultiFile = IsMultiFile, MultiFilePattern = _multiFileOptions.FormatPattern, MultiFileMaxDays = _multiFileOptions.MaxDayTry, - CurrentLine = dataGridView.CurrentCellAddress.Y, - FirstDisplayedLine = dataGridView.FirstDisplayedScrollingRowIndex, + CurrentLine = CurrentLogicalLine, + FirstDisplayedLine = RowToLine(dataGridView.FirstDisplayedScrollingRowIndex), FilterVisible = !splitContainerLogWindow.Panel2Collapsed, FilterAdvanced = !advancedFilterSplitContainer.Panel1Collapsed, FilterPosition = splitContainerLogWindow.SplitterDistance, @@ -6097,13 +6128,13 @@ public void SetColumnizer (ILogLineMemoryColumnizer columnizer, BufferedDataGrid ApplyFrozenState(gridView); } - public IColumnMemory GetCellValue (int rowIndex, int columnIndex) + public IColumnMemory GetCellValue (int lineNum, int columnIndex) { if (columnIndex == 1) { return new Column { - FullValue = $"{rowIndex + 1}".AsMemory() // line number + FullValue = $"{lineNum + 1}".AsMemory() // line number }; } @@ -6114,7 +6145,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex) try { - var cols = _columnCache.GetColumnsForLine(_logFileReader, rowIndex, CurrentColumnizer, ColumnizerCallbackObject); + var cols = _columnCache.GetColumnsForLine(_logFileReader, lineNum, CurrentColumnizer, ColumnizerCallbackObject); if (cols != null && cols.ColumnValues != null) { if (columnIndex <= cols.ColumnValues.Length + 1) @@ -6135,7 +6166,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex) catch (IndexOutOfRangeException ex) { - _logger.Warn(ex, "Failed to get cell value due to index error. rowIndex={RowIndex}, columnIndex={ColumnIndex}", rowIndex, columnIndex); + _logger.Warn(ex, "Failed to get cell value due to index error. lineNum={LineNum}, columnIndex={ColumnIndex}", lineNum, columnIndex); return Column.EmptyColumn; } #else @@ -6149,7 +6180,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex) catch (ArgumentOutOfRangeException ex) { - _logger.Warn(ex, "Failed to get cell value due to argument range error. rowIndex={RowIndex}, columnIndex={ColumnIndex}", rowIndex, columnIndex); + _logger.Warn(ex, "Failed to get cell value due to argument range error. lineNum={LineNum}, columnIndex={ColumnIndex}", lineNum, columnIndex); return Column.EmptyColumn; } #else @@ -6162,7 +6193,7 @@ public IColumnMemory GetCellValue (int rowIndex, int columnIndex) catch (NullReferenceException ex) { - _logger.Warn(ex, "Failed to get cell value due to null state. rowIndex={RowIndex}, columnIndex={ColumnIndex}", rowIndex, columnIndex); + _logger.Warn(ex, "Failed to get cell value due to null state. lineNum={LineNum}, columnIndex={ColumnIndex}", lineNum, columnIndex); return Column.EmptyColumn; } #else @@ -6262,9 +6293,14 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi return; } - if (isFilteredGridView) + rowIndex = isFilteredGridView + ? _filterResultList[rowIndex] + : RowToLine(rowIndex); + + if (rowIndex < 0) { - rowIndex = _filterResultList[rowIndex]; + e.Handled = false; + return; } // Ensure prefetch is current — CellPainting fires BEFORE CellValueNeeded on scroll jumps, @@ -6442,12 +6478,13 @@ public void FollowTailChanged (bool isChecked, bool byTrigger) if (_guiStateArgs.FollowTail && _logFileReader != null) { - if (dataGridView.RowCount >= _logFileReader.LineCount && _logFileReader.LineCount > 0) + // Follow the last visible row; hidden lines are never revealed by tailing. + if (_rowMap.LineCount >= _logFileReader.LineCount && dataGridView.RowCount > 0) { // Mark stale instead of invalidating — keeps old buffers pinned until // the next Prefetch atomically swaps in new pins. _columnCache.MarkPrefetchStale(); - dataGridView.FirstDisplayedScrollingRowIndex = _logFileReader.LineCount - 1; + dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1; } } @@ -6507,7 +6544,7 @@ private void ApplyPendingLineNavigation () } FollowTailChanged(false, false); - if (dataGridView.RowCount > 0) + if (_rowMap.LineCount > 0) { dataGridView.ClearSelection(); GotoLine(targetLine.Value - 1); @@ -6526,14 +6563,8 @@ public void GotoLine (int line) { if (line >= 0) { - if (line < dataGridView.RowCount) - { - SelectLine(line, false, true); - } - else - { - SelectLine(dataGridView.RowCount - 1, false, true); - } + // An original line; beyond the end selects the last line, a hidden one is revealed. + SelectLine(Math.Min(line, _rowMap.LineCount - 1), false, true); _ = dataGridView.Focus(); } @@ -6546,8 +6577,8 @@ public void StartSearch () var searchParams = _logWindowCoordinator.SearchParams; searchParams.CurrentLine = LogSearcher.ResolveDirection(searchParams) == SearchDirection.Forward - ? dataGridView.CurrentCellAddress.Y + 1 - : dataGridView.CurrentCellAddress.Y - 1; + ? CurrentLogicalLine + 1 + : CurrentLogicalLine - 1; _currentSearchParams = searchParams; // remember for async "not found" messages TrackMarkerSearch(searchParams); @@ -6556,7 +6587,7 @@ public void StartSearch () StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel); _progressEventArgs.MinValue = 0; - _progressEventArgs.MaxValue = dataGridView.RowCount; + _progressEventArgs.MaxValue = _logFileReader.LineCount; _progressEventArgs.Value = 0; _progressEventArgs.Visible = true; SendProgressBarUpdate(); @@ -6625,28 +6656,34 @@ public void SelectLogLine (int lineNumber) _ = Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), lineNumber, true); } + /// Explicit navigation to an original line; a hidden line is revealed (see ). public void SelectAndEnsureVisible (int line, bool triggerSyncCall) + { + SelectAndEnsureVisibleRow(RevealLine(line), triggerSyncCall); + } + + private void SelectAndEnsureVisibleRow (int row, bool triggerSyncCall) { try { - SelectLine(line, triggerSyncCall, false); + SelectRow(row, triggerSyncCall, false); //if (!this.dataGridView.CurrentRow.Displayed) - if (line < dataGridView.FirstDisplayedScrollingRowIndex || line > dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false)) + if (row < dataGridView.FirstDisplayedScrollingRowIndex || row > dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false)) { - dataGridView.FirstDisplayedScrollingRowIndex = line; - for (var i = 0; i < 8 && dataGridView.FirstDisplayedScrollingRowIndex > 0 && line < dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false); ++i) + dataGridView.FirstDisplayedScrollingRowIndex = row; + for (var i = 0; i < 8 && dataGridView.FirstDisplayedScrollingRowIndex > 0 && row < dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false); ++i) { dataGridView.FirstDisplayedScrollingRowIndex -= 1; } - if (line >= dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false)) + if (row >= dataGridView.FirstDisplayedScrollingRowIndex + dataGridView.DisplayedRowCount(false)) { dataGridView.FirstDisplayedScrollingRowIndex += 1; } } - dataGridView.CurrentCell = dataGridView.Rows[line].Cells[0]; + dataGridView.CurrentCell = dataGridView.Rows[row].Cells[0]; } catch (Exception e) { @@ -6699,11 +6736,11 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) } case Keys.Down when e.Modifiers == Keys.Alt: { - var newLine = _logFileReader.GetNextMultiFileLine(dataGridView.CurrentCellAddress.Y); + var newLine = _logFileReader.GetNextMultiFileLine(CurrentLogicalLine); if (newLine != -1) { - SelectLine(newLine, false, true); + SelectNearestLine(newLine, false, true); } e.Handled = true; @@ -6712,11 +6749,11 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) } case Keys.Up when e.Modifiers == Keys.Alt: { - var newLine = _logFileReader.GetPrevMultiFileLine(dataGridView.CurrentCellAddress.Y); + var newLine = _logFileReader.GetPrevMultiFileLine(CurrentLogicalLine); if (newLine != -1) { - SelectLine(newLine - 1, false, true); + SelectNearestLine(newLine - 1, false, true); } e.Handled = true; @@ -6785,9 +6822,10 @@ public void AddBookmarkOverlays () } } - if (_bookmarkProvider.IsBookmarkAtLine(i)) + var lineNum = RowToLine(i); + if (_bookmarkProvider.IsBookmarkAtLine(lineNum)) { - var bookmark = _bookmarkProvider.GetBookmarkForLine(i); + var bookmark = _bookmarkProvider.GetBookmarkForLine(lineNum); if (bookmark.Text.Length > 0) { //BookmarkOverlay overlay = new BookmarkOverlay(); @@ -6865,7 +6903,7 @@ public void ToggleBookmark () return; } - lineNum = dataGridView.CurrentCellAddress.Y; + lineNum = CurrentLogicalLine; } ToggleBookmark(lineNum); @@ -7158,7 +7196,7 @@ public void JumpNextBookmark () } else { - var index = FindNextBookmarkIndex(dataGridView.CurrentCellAddress.Y); + var index = FindNextBookmarkIndex(CurrentLogicalLine); if (index > _bookmarkProvider.Bookmarks.Count - 1) { index = 0; @@ -7217,7 +7255,7 @@ public void JumpPrevBookmark () } else { - var index = FindPrevBookmarkIndex(dataGridView.CurrentCellAddress.Y); + var index = FindPrevBookmarkIndex(CurrentLogicalLine); if (index < 0) { index = _bookmarkProvider.Bookmarks.Count - 1; @@ -7373,7 +7411,7 @@ public void CopyMarkedLinesToTab () { if (row.Index != -1) { - lineNumList.Add(row.Index); + lineNumList.Add(RowToLine(row.Index)); } } @@ -7434,8 +7472,8 @@ public void Reload () _reloadMemento = new ReloadMemento { - CurrentLine = dataGridView.CurrentCellAddress.Y, - FirstDisplayedLine = dataGridView.FirstDisplayedScrollingRowIndex + CurrentLine = CurrentLogicalLine, + FirstDisplayedLine = RowToLine(dataGridView.FirstDisplayedScrollingRowIndex) }; _forcedColumnizerForLoading = CurrentColumnizer; @@ -7533,14 +7571,14 @@ public bool ScrollToTimestamp (DateTime timestamp, bool roundToSeconds, bool tri public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bool triggerSyncCall) { var hasScrolled = false; - if (!CurrentColumnizer.IsTimeshiftImplemented() || dataGridView.RowCount == 0) + if (!CurrentColumnizer.IsTimeshiftImplemented() || _rowMap.LineCount == 0) { return false; } //this.Cursor = Cursors.WaitCursor; - var currentLine = dataGridView.CurrentCellAddress.Y; - if (currentLine < 0 || currentLine >= dataGridView.RowCount) + var currentLine = CurrentLogicalLine; + if (currentLine < 0) { currentLine = 0; } @@ -7588,7 +7626,7 @@ public void AppFocusGained () public ILogLineMemory GetCurrentLine () { return dataGridView.CurrentRow != null && dataGridView.CurrentRow.Index != -1 - ? _logFileReader.GetLogLineMemory(dataGridView.CurrentRow.Index) + ? _logFileReader.GetLogLineMemory(RowToLine(dataGridView.CurrentRow.Index)) : null; } @@ -7610,7 +7648,7 @@ public int GetRealLineNum () public ILogFileInfo GetCurrentFileInfo () { return dataGridView.CurrentRow != null && dataGridView.CurrentRow.Index != -1 - ? _logFileReader.GetLogFileInfoForLine(dataGridView.CurrentRow.Index) + ? _logFileReader.GetLogFileInfoForLine(RowToLine(dataGridView.CurrentRow.Index)) : null; } @@ -7759,6 +7797,7 @@ public void SetCurrentHighlightGroup (string groupName) } InvalidateMarkerCriteria(MarkerScanSource.Highlights); + RebuildLineVisibility(); SendGuiStateUpdate(); diff --git a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs index f660de5b..0454d00e 100644 --- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs +++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.Designer.cs @@ -54,6 +54,7 @@ private void InitializeComponent () _btnBookmarkComment = new Button(); _checkBoxStopTail = new CheckBox(); _checkBoxDontDirtyLed = new CheckBox(); + _checkBoxHideLine = new CheckBox(); _checkBoxPlugin = new CheckBox(); _btnSelectPlugin = new Button(); _checkBoxAlertOnHit = new CheckBox(); @@ -280,6 +281,7 @@ private void InitializeComponent () _tabActions.Controls.Add(_labelCooldown); _tabActions.Controls.Add(_numericCooldownSeconds); _tabActions.Controls.Add(_labelCooldownSeconds); + _tabActions.Controls.Add(_checkBoxHideLine); _tabActions.Location = new Point(4, 24); _tabActions.Name = "_tabActions"; _tabActions.Padding = new Padding(8); @@ -329,6 +331,16 @@ private void InitializeComponent () _checkBoxDontDirtyLed.Text = "Dont Dirty Led"; _checkBoxDontDirtyLed.UseVisualStyleBackColor = true; // + // _checkBoxHideLine + // + _checkBoxHideLine.AutoSize = true; + _checkBoxHideLine.Location = new Point(12, 250); + _checkBoxHideLine.Name = "_checkBoxHideLine"; + _checkBoxHideLine.Size = new Size(74, 19); + _checkBoxHideLine.TabIndex = 13; + _checkBoxHideLine.Text = "Hide line"; + _checkBoxHideLine.UseVisualStyleBackColor = true; + // // _checkBoxPlugin // _checkBoxPlugin.AutoSize = true; @@ -485,6 +497,7 @@ private void InitializeComponent () private System.Windows.Forms.Button _btnBookmarkComment; private System.Windows.Forms.CheckBox _checkBoxStopTail; private System.Windows.Forms.CheckBox _checkBoxDontDirtyLed; + private System.Windows.Forms.CheckBox _checkBoxHideLine; private System.Windows.Forms.CheckBox _checkBoxPlugin; private System.Windows.Forms.Button _btnSelectPlugin; private System.Windows.Forms.CheckBox _checkBoxAlertOnHit; diff --git a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs index ad5ecd30..aa667d2c 100644 --- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs +++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs @@ -69,6 +69,7 @@ private void ApplyResources () _btnBookmarkComment.Text = Resources.HighlightDialog_UI_Button_BookmarkComment; _checkBoxStopTail.Text = Resources.HighlightDialog_UI_CheckBox_StopTail; _checkBoxDontDirtyLed.Text = Resources.HighlightDialog_UI_CheckBox_DontDirtyLed; + _checkBoxHideLine.Text = Resources.HighlightDialog_UI_CheckBox_HideLine; _checkBoxPlugin.Text = Resources.HighlightDialog_UI_CheckBox_Plugin; _btnSelectPlugin.Text = Resources.HighlightDialog_UI_Button_SelectPlugin; _checkBoxAlertOnHit.Text = Resources.HighlightDialog_UI_CheckBox_AlertOnHit; @@ -97,6 +98,7 @@ private void LoadFromEntry () _checkBoxBookmark.Checked = _entry.IsSetBookmark; _checkBoxStopTail.Checked = _entry.IsStopTail; _checkBoxDontDirtyLed.Checked = _entry.IsLedSwitch; + _checkBoxHideLine.Checked = _entry.IsHideLine; _checkBoxPlugin.Checked = _entry.IsActionEntry; _checkBoxAlertOnHit.Checked = _entry.AlertOnHit; @@ -145,6 +147,7 @@ private void SaveToEntry () _entry.BookmarkComment = _bookmarkComment; _entry.IsStopTail = _checkBoxStopTail.Checked; _entry.IsLedSwitch = _checkBoxDontDirtyLed.Checked; + _entry.IsHideLine = _checkBoxHideLine.Checked; _entry.IsActionEntry = _checkBoxPlugin.Checked; _entry.ActionEntry = (ActionEntry)_currentActionEntry.Clone(); diff --git a/src/LogExpert.UI/Interface/ILogPaintContextUI.cs b/src/LogExpert.UI/Interface/ILogPaintContextUI.cs index 3c11641e..2b4be6f8 100644 --- a/src/LogExpert.UI/Interface/ILogPaintContextUI.cs +++ b/src/LogExpert.UI/Interface/ILogPaintContextUI.cs @@ -31,7 +31,7 @@ internal interface ILogPaintContextUI : ILogLineSource ILogLineMemory GetLogLineMemory (int lineNum); - IColumnMemory GetCellValue (int rowIndex, int columnIndex); + IColumnMemory GetCellValue (int lineNum, int columnIndex); Bookmark GetBookmarkForLine (int lineNum); From 5690c21c9b8f608c1eed1fc5980cb255e47ed0e8 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 26 Sep 2026 19:55:14 +0200 Subject: [PATCH 03/11] Never read lines under the visibility tracker lock (#338) The UI thread takes the tracker lock (Load/Rebuild/IsScanning) while a reader call made under it could wait for the UI thread, which could hang the window. Tail/scan evaluation now reads outside the lock and commits only if the state is unchanged. Also cancels the scan when the window closes, fixes marker navigation to lines beyond the visible row count, adds lifecycle/trigger/marker/perf tests and the CONTEXT.md glossary. --- CONTEXT.md | 26 +++ .../Classes/Highlight/LineVisibilityMap.cs | 3 +- .../Highlight/LineVisibilityTracker.cs | 165 +++++++++++------- .../Controls/ColumnCacheTests.cs | 38 ++++ .../Highlight/LineVisibilityTrackerTests.cs | 26 +++ .../HiddenLinesWindowTests.cs | 94 ++++++++++ .../LogWindow/LogWindow.LineVisibility.cs | 2 + .../Controls/LogWindow/LogWindow.MarkerBar.cs | 2 +- .../Controls/LogWindow/LogWindow.cs | 1 + 9 files changed, 294 insertions(+), 63 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f203b80b..e657f23a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,6 +23,32 @@ meaning; do not redefine them locally. construction, since the bulk `HighlightBookmarkScanner` has no access to the side-effecting triggers. +## Line hiding + +- **Hide-line rule** — A Highlight Entry with `IsHideLine` set. A line is a + **Hidden Line** when any matching hide-line rule of the active Highlight Group + matches it (`HighlightEvaluator.IsHidden`); no other entry can cancel that, + whatever the order. Display state only: hidden lines stay in the reader and + remain eligible for Log Search, the Window Filter, Filter Pipes and triggers. + Temporary search-hit entries never hide. +- **Original logical line** — A line index of the Logfile Reader, before hiding. + Reader access, bookmarks, timestamps, row heights, sessions and markers use it. +- **Visible row** — A row of the Log Window's main grid after hiding. Only grid + code deals in rows; convert with `RowToLine` / `LineToRow`. +- **Line Visibility Map** (`LineVisibilityMap`) — The immutable mapping between + visible rows and original logical lines, owned by the **Line Visibility + Tracker** (`LineVisibilityTracker`), which rebuilds it on a cancellable + background scan and extends it on the tail path. Visibility scans never fire + triggers. +- **Show hidden lines** — The per-window, transient override that displays + hidden lines without changing any rule. Explicit navigation to a hidden line + (Go to Line, bookmarks, Log Search, filter results, time sync, markers, the + command-line target) turns it on; tailing and restoring saved positions never + do — they select the nearest visible line instead. + +*Avoid*: "filtered out" for hidden lines (filtering is the **Window Filter**), +"row" when an original logical line is meant. + ## Marker Bar - **Marker Bar** — A compact overview beside a Log Window that shows where diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs index 4c0af88f..68ad2437 100644 --- a/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs @@ -119,11 +119,12 @@ public LineVisibilityMap Append (int lineCount, IReadOnlyList hiddenLines) ArgumentOutOfRangeException.ThrowIfLessThan(lineCount, LineCount); var previous = LineCount - 1; + // Hidden lines must be ascending and within the appended range. foreach (var line in hiddenLines) { if (line <= previous || line >= lineCount) { - throw new ArgumentOutOfRangeException(nameof(hiddenLines), line, "Hidden lines must be ascending and within the appended range."); + throw new ArgumentOutOfRangeException(nameof(hiddenLines), line, null); } previous = line; diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs index 4199c106..e285a0f3 100644 --- a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs @@ -12,6 +12,11 @@ namespace LogExpert.Core.Classes.Highlight; /// evaluated synchronously by the caller's thread. Evaluation uses only, /// so it can never fire a trigger. /// +/// +/// Lines are never read while the lock is held: the UI thread takes the lock too, and a reader call may wait for the +/// UI thread. Evaluations run against a state snapshot and commit only if the state () is +/// unchanged, retrying otherwise. +/// /// public sealed class LineVisibilityTracker : IDisposable { @@ -19,11 +24,12 @@ public sealed class LineVisibilityTracker : IDisposable private readonly Lock _lock = new(); private volatile LineVisibilityMap _map = LineVisibilityMap.Empty; + private volatile HighlightEntry[]? _pendingRules; private HighlightEntry[] _rules = []; - private HighlightEntry[]? _pendingRules; private CancellationTokenSource? _scanCts; private Task _scanTask = Task.CompletedTask; private int _generation; + private int _version; private bool _disposed; /// Reads an original line; read live, since the reader is replaced on reload. @@ -41,16 +47,7 @@ public LineVisibilityTracker (Func getLine) public LineVisibilityMap Map => _map; - public bool IsScanning - { - get - { - lock (_lock) - { - return _pendingRules != null; - } - } - } + public bool IsScanning => _pendingRules != null; /// New content was loaded: every line is visible until the scan with completes. public void Load (int lineCount, IEnumerable entries) @@ -63,8 +60,7 @@ public void Load (int lineCount, IEnumerable entries) return; } - _rules = []; - _map = LineVisibilityMap.Identity(lineCount); + SetStateLocked(LineVisibilityMap.Identity(lineCount), []); StartOrStopScanLocked(rules); } } @@ -84,8 +80,8 @@ public void Rebuild (IEnumerable entries) StartOrStopScanLocked(rules); if (rules.Length == 0 && _map.HiddenCount > 0) { - _rules = []; - _map = cleared = LineVisibilityMap.Identity(_map.LineCount); + cleared = LineVisibilityMap.Identity(_map.LineCount); + SetStateLocked(cleared, []); } } @@ -98,20 +94,40 @@ public void Rebuild (IEnumerable entries) /// Tail path: evaluates lines appended up to and returns the current map. public LineVisibilityMap Extend (int lineCount) { - Exception? error; - LineVisibilityMap map; - lock (_lock) + while (true) { - if (_disposed || lineCount <= _map.LineCount) + LineVisibilityMap from; + HighlightEntry[] rules; + int version; + lock (_lock) { - return _map; + if (_disposed || lineCount <= _map.LineCount) + { + return _map; + } + + (from, rules, version) = (_map, _rules, _version); } - (map, error) = EvaluateLocked(_map, lineCount); - } + var (map, error) = TryEvaluate(from, rules, lineCount); + lock (_lock) + { + if (_disposed) + { + return _map; + } + + if (version != _version) + { + continue; + } - RaiseFailure(map, error); - return map; + SetStateLocked(map, error == null ? rules : []); + } + + RaiseFailure(map, error); + return map; + } } /// Tail path, rollover: the first lines were dropped. @@ -121,7 +137,7 @@ public LineVisibilityMap Shift (int offset) { if (!_disposed) { - _map = _map.Shift(offset); + SetStateLocked(_map.Shift(offset), _rules); RestartPendingScanLocked(); } @@ -132,21 +148,40 @@ public LineVisibilityMap Shift (int offset) /// Tail path, truncation: the content was replaced and is re-evaluated up to . public LineVisibilityMap Replace (int lineCount) { - Exception? error; - LineVisibilityMap map; - lock (_lock) + while (true) { - if (_disposed) + HighlightEntry[] rules; + int version; + lock (_lock) { - return _map; + if (_disposed) + { + return _map; + } + + (rules, version) = (_rules, _version); } - (map, error) = EvaluateLocked(LineVisibilityMap.Empty, lineCount); - RestartPendingScanLocked(); - } + var (map, error) = TryEvaluate(LineVisibilityMap.Empty, rules, lineCount); + lock (_lock) + { + if (_disposed) + { + return _map; + } + + if (version != _version) + { + continue; + } - RaiseFailure(map, error); - return map; + SetStateLocked(map, error == null ? rules : []); + RestartPendingScanLocked(); + } + + RaiseFailure(map, error); + return map; + } } /// Completes once no scan is running (including scans restarted meanwhile). @@ -183,6 +218,7 @@ public void Dispose () _disposed = true; _generation++; + _version++; _pendingRules = null; _scanCts?.Cancel(); _scanCts?.Dispose(); @@ -190,6 +226,13 @@ public void Dispose () } } + private void SetStateLocked (LineVisibilityMap map, HighlightEntry[] rules) + { + _map = map; + _rules = rules; + _version++; + } + private void StartOrStopScanLocked (HighlightEntry[] rules) { _generation++; @@ -219,34 +262,36 @@ private void RestartPendingScanLocked () } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Any evaluation failure falls back to showing every line and is reported")] private void Scan (HighlightEntry[] rules, int lineCount, int generation, CancellationToken token) { LineVisibilityMap map; Exception? error = null; try { - List hidden = []; - for (var i = 0; i < lineCount; i++) - { - token.ThrowIfCancellationRequested(); - if (IsHiddenLine(rules, i)) - { - hidden.Add(i); - } - } + map = Evaluate(LineVisibilityMap.Empty, rules, lineCount, token); - lock (_lock) + // Catch up with lines the tail appended while the scan ran, outside the lock, until none are left. + while (true) { - if (generation != _generation) + int known; + lock (_lock) { - return; + if (generation != _generation) + { + return; + } + + known = _map.LineCount; + if (known <= map.LineCount) + { + SetStateLocked(map, rules); + _pendingRules = null; + break; + } } - // Catch up with lines the tail appended while the scan ran. - map = Evaluate(LineVisibilityMap.Empty.Append(lineCount, hidden), rules, _map.LineCount); - _map = map; - _rules = rules; - _pendingRules = null; + map = Evaluate(map, rules, known, token); } } catch (OperationCanceledException) @@ -262,8 +307,8 @@ private void Scan (HighlightEntry[] rules, int lineCount, int generation, Cancel return; } - _map = map = LineVisibilityMap.Identity(_map.LineCount); - _rules = []; + map = LineVisibilityMap.Identity(_map.LineCount); + SetStateLocked(map, []); _pendingRules = null; error = ex; } @@ -272,23 +317,20 @@ private void Scan (HighlightEntry[] rules, int lineCount, int generation, Cancel Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error)); } - private (LineVisibilityMap Map, Exception? Error) EvaluateLocked (LineVisibilityMap from, int lineCount) + private (LineVisibilityMap Map, Exception? Error) TryEvaluate (LineVisibilityMap from, HighlightEntry[] rules, int lineCount) { try { - _map = Evaluate(from, _rules, lineCount); - return (_map, null); + return (Evaluate(from, rules, lineCount, CancellationToken.None), null); } catch (Exception ex) when (ex is not OperationCanceledException) { // Never publish a partial state: fall back to showing every line. - _rules = []; - _map = LineVisibilityMap.Identity(lineCount); - return (_map, ex); + return (LineVisibilityMap.Identity(lineCount), ex); } } - private LineVisibilityMap Evaluate (LineVisibilityMap from, HighlightEntry[] rules, int lineCount) + private LineVisibilityMap Evaluate (LineVisibilityMap from, HighlightEntry[] rules, int lineCount, CancellationToken token) { if (lineCount <= from.LineCount) { @@ -300,6 +342,7 @@ private LineVisibilityMap Evaluate (LineVisibilityMap from, HighlightEntry[] rul { for (var i = from.LineCount; i < lineCount; i++) { + token.ThrowIfCancellationRequested(); if (IsHiddenLine(rules, i)) { hidden.Add(i); diff --git a/src/LogExpert.Tests/Controls/ColumnCacheTests.cs b/src/LogExpert.Tests/Controls/ColumnCacheTests.cs index 55270c2c..b1e6c564 100644 --- a/src/LogExpert.Tests/Controls/ColumnCacheTests.cs +++ b/src/LogExpert.Tests/Controls/ColumnCacheTests.cs @@ -140,4 +140,42 @@ public void MarkPrefetchStale_KeepsBuffersPinned_While_InvalidateReleasesThem () cache.InvalidatePrefetch(); Assert.That(buffer.IsPinned, Is.False, "InvalidatePrefetch must release the pins."); } + + /// + /// Hide-line rules (#338) make visible rows non-contiguous: only the runs of displayed lines are pinned and read, + /// never the gap between them, and replacing the prefetch releases the previous pins. + /// + [Test] + public void PrefetchLines_PinsAndReadsOnlyTheRunsOfDisplayedLines () + { + var fileInfo = new Mock(); + _ = fileInfo.Setup(f => f.FullName).Returns("fake.log"); + var buffers = new List(); + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.GetLogLineMemories(It.IsAny(), It.IsAny())) + .Returns((int start, int count) => [.. Enumerable.Range(start, count).Select(i => (ILogLineMemory)new LogLine($"line {i}", i))]); + _ = readerMock.As() + .Setup(p => p.PinRange(It.IsAny(), It.IsAny())) + .Returns((int start, int _) => + { + var buffer = new LogBuffer(fileInfo.Object, 10) { StartLine = start }; + buffer.Pin(); + buffers.Add(buffer); + return new PinHandle([buffer]); + }); + var cache = new ColumnCache(); + + cache.PrefetchLines(readerMock.Object, [2, 3, 1000, 1001, 1002]); + + readerMock.As().Verify(p => p.PinRange(2, 3), Times.Once); + readerMock.As().Verify(p => p.PinRange(1000, 1002), Times.Once); + readerMock.Verify(r => r.GetLogLineMemories(2, 2), Times.Once); + readerMock.Verify(r => r.GetLogLineMemories(1000, 3), Times.Once); + Assert.That(cache.GetPrefetchedLine(1001)?.FullLine.ToString(), Is.EqualTo("line 1001")); + Assert.That(cache.GetPrefetchedLine(500), Is.Null); + + cache.Prefetch(readerMock.Object, 0, 1); + Assert.That(buffers.Take(2).All(b => !b.IsPinned), Is.True, "the sparse pins must be released once replaced"); + Assert.That(cache.GetPrefetchedLine(1001), Is.Null); + } } diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs index 83eb8b83..03c4aff7 100644 --- a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs +++ b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs @@ -270,6 +270,32 @@ public void TailFailure_FallsBackToAllVisible () Assert.That(map.HiddenCount, Is.Zero); } + [Test] + public void ReaderCallInProgress_NeverBlocksTheOtherCallers () + { + _tracker.Load(_lines.Count, HideDebug); + Idle(); + _lines.Add("DEBUG f"); + _gate.Reset(); + var extend = Task.Run(() => _tracker.Extend(6)); + Thread.Sleep(50); + + // The UI thread calls these; a reader call may itself wait for the UI thread, so none may wait for it. + var others = Task.Run(() => + { + _ = _tracker.IsScanning; + _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]); + _ = _tracker.Shift(0); + _tracker.Load(_lines.Count, HideDebug); + }); + + Assert.That(others.Wait(TimeSpan.FromSeconds(2)), Is.True, "a caller waited for a blocked reader call"); + _gate.Set(); + Assert.That(extend.Wait(Timeout), Is.True); + Idle(); + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 0, 2, 4 })); + } + [Test] public void Dispose_CancelsAPendingScan_WithoutPublishing () { diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs index dcd371d9..9cd02705 100644 --- a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs +++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs @@ -253,6 +253,41 @@ public void FollowTail_TracksTheLastVisibleLine_WithoutRevealingAppendedHiddenLi Assert.That(grid.FirstDisplayedScrollingRowIndex + grid.DisplayedRowCount(false), Is.GreaterThanOrEqualTo(grid.RowCount)); } + [Test] + public void Reload_KeepsTheSelectedOriginalLine_AndHidesAgain () + { + var log = Open(); + log.GotoLine(14); + var finished = false; + log.ProgressBarUpdate += (_, progress) => finished |= !progress.Visible; + + log.Reload(); + PumpUntil(() => finished); + var loaded = Task.Run(log.WaitForLoadingFinished); + PumpUntil(() => loaded.IsCompleted); + WaitForVisibility(log); + + Assert.That(Grid(log).RowCount, Is.EqualTo(LINE_COUNT / 2)); + Assert.That(log.CurrentLineNum, Is.EqualTo(14)); + } + + [Test] + public void TailTriggers_StillFireOnHiddenLines_WithoutRevealingThem () + { + _settings.Preferences.HighlightGroupList[0].HighlightEntryList.Add(new HighlightEntry { SearchText = "DEBUG 21", IsStopTail = true, IsSetBookmark = true }); + var log = Open(); + log.FollowTailChanged(true, false); + + File.AppendAllLines(_fileName, [Text(20), Text(21), Text(22)]); + PumpUntil(() => log.GatherSessionSnapshot().LineCount == 23); + PumpUntil(() => log.BookmarkData.IsBookmarkAtLine(21)); + PumpFor(TimeSpan.FromMilliseconds(100)); + + Assert.That(log.GatherSessionSnapshot().FollowTail, Is.False); + Assert.That(log.ShowHiddenLines, Is.False); + Assert.That(log.CurrentLineNum, Is.EqualTo(22), "stop-tail scrolls to the next visible line"); + } + [Test] public void CopySelectedRows_CopiesOnlyTheSelectedVisibleLines () { @@ -340,6 +375,23 @@ public void WindowFilter_StillFindsHiddenLines () Assert.That(Grid(log).RowCount, Is.EqualTo(LINE_COUNT / 2)); } + [Test] + public void MarkerClick_OnHiddenLine_RevealsIt () + { + _settings.Preferences.ShowMarkerBar = true; + var log = Open(); + log.ToggleBookmark(11); + PumpFor(TimeSpan.FromMilliseconds(600)); + var bar = Find(log, "markerBar"); + + // The bar raises LineSelected with the original line of the clicked marker; 11 is beyond the 10 visible rows. + _ = typeof(LogWindow).GetMethod("OnMarkerLineSelected", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(log, [bar, new Core.EventArguments.SelectLineEventArgs(11)]); + + Assert.That(log.CurrentLineNum, Is.EqualTo(11)); + Assert.That(log.ShowHiddenLines, Is.True); + } + [Test] public void CommandLineTarget_OnHiddenLine_RevealsItAfterVisibilityInitialization () { @@ -371,6 +423,48 @@ public void CommandLineTarget_OnVisibleLine_KeepsLinesHidden () Assert.That(log.CurrentLineNum, Is.EqualTo(6)); } + [Test, Explicit("Real-file hide-line scan duration, memory and UI responsiveness experiment")] + public void LargeFile_ReportsScanDurationMemoryAndUiResponsiveness () + { + const int LARGE_LINE_COUNT = 1_000_000; + File.WriteAllLines(_fileName, Enumerable.Range(0, LARGE_LINE_COUNT) + .Select(i => $"2026-09-26 12:34:56.{i % 1000:D3} {(i % 2 == 1 ? "DEBUG" : "INFO")} worker-{i % 16} message number {i}")); + HideRule.IsHideLine = false; + var log = Open(); + var grid = Grid(log); + var memoryBefore = GC.GetTotalMemory(true); + + HideRule.IsHideLine = true; + var elapsed = Stopwatch.StartNew(); + log.SetCurrentHighlightGroup("hide"); + var pump = Stopwatch.StartNew(); + var maximumPump = TimeSpan.Zero; + while (grid.RowCount != LARGE_LINE_COUNT / 2 && elapsed.Elapsed < TimeSpan.FromSeconds(120)) + { + pump.Restart(); + PumpOnce(); + maximumPump = maximumPump > pump.Elapsed ? maximumPump : pump.Elapsed; + Thread.Sleep(1); + } + + var scan = elapsed.Elapsed; + Assert.That(grid.RowCount, Is.EqualTo(LARGE_LINE_COUNT / 2)); + var memoryAfter = GC.GetTotalMemory(true); + + elapsed.Restart(); + log.GotoLine(LARGE_LINE_COUNT - 1); + var reveal = elapsed.Elapsed; + elapsed.Restart(); + log.ShowHiddenLines = false; + grid.FirstDisplayedScrollingRowIndex = grid.RowCount / 2; + PumpOnce(); + var scroll = elapsed.Elapsed; + + TestContext.Progress.WriteLine($"Hide-line scan: {new FileInfo(_fileName).Length} bytes, {LARGE_LINE_COUNT} lines, {log.HiddenLineCount} hidden; " + + $"scan {scan.TotalMilliseconds:F0} ms; managed delta {memoryAfter - memoryBefore} bytes; maximum UI pump during scan {maximumPump.TotalMilliseconds:F1} ms; " + + $"reveal hidden last line {reveal.TotalMilliseconds:F0} ms; override off + mid-file scroll {scroll.TotalMilliseconds:F0} ms."); + } + private LogWindow Open () { _window = new LogTabWindow([_fileName], 1, false, _config.Object) { ShowInTaskbar = false, Opacity = 0 }; diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs index 345df2d6..286edf53 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Globalization; using LogExpert.Core.Classes.Highlight; @@ -30,6 +31,7 @@ internal partial class LogWindow internal int HiddenLineCount => _lineVisibility.Map.HiddenCount; /// The per-window "Show hidden lines" override. Transient: not saved in the Session File. + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] internal bool ShowHiddenLines { get => _showHiddenLines; diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs index b5a9db3f..1506825f 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.MarkerBar.cs @@ -336,7 +336,7 @@ private void ReportMarkerError (MarkerSnapshot snapshot, ref MarkerSnapshot? rep private void OnMarkerLineSelected (object? sender, SelectLineEventArgs eventArgs) { if (_markerFrame?.Generation == Volatile.Read(ref _markerGeneration) - && eventArgs.Line >= 0 && eventArgs.Line < dataGridView.RowCount) + && eventArgs.Line >= 0 && eventArgs.Line < _rowMap.LineCount) { RequestGotoLine(eventArgs.Line + 1); } diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 0c7bf742..c034d0ee 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -6028,6 +6028,7 @@ public void CloseLogWindow () CancelPendingLineNavigation(); CancelHighlightBookmarkScan(); + _lineVisibility.Dispose(); StopTimespreadThread(); StopTimestampSyncThread(); _tailFollowEngine.Stop(); From 4e0bd6b772bed0c8af34d47c0aa2638f802fb4be Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 26 Sep 2026 20:47:08 +0200 Subject: [PATCH 04/11] Show no rows until the first hide scan; time-sync followers stay hidden (#338) - While a load's first visibility scan runs the grid shows no rows, and saved/reload positions are applied once it has published. - Windows following a time sync select the nearest visible line; only the window the user navigates in reveals a hidden line. - Review fixes: shared evaluate-and-commit loop in the tracker, one publish path for the grid, CurrentLineNum instead of a duplicate property, line/row naming, unsubscribe on close, drop unused Truncate. --- .../Classes/Highlight/LineVisibilityMap.cs | 13 -- .../Highlight/LineVisibilityTracker.cs | 79 +++++------ .../Highlight/LineVisibilityMapTests.cs | 12 -- .../Highlight/LineVisibilityTrackerTests.cs | 29 ++++ .../HiddenLinesWindowTests.cs | 72 ++++++++++ .../LogWindow/LogWindow.LineVisibility.cs | 88 +++++++++--- .../Controls/LogWindow/LogWindow.cs | 133 +++++++----------- 7 files changed, 254 insertions(+), 172 deletions(-) diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs index 68ad2437..79d65876 100644 --- a/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityMap.cs @@ -155,19 +155,6 @@ public LineVisibilityMap Shift (int offset) return new LineVisibilityMap(new HiddenLineStore(shifted, shifted.Length, lineCount), lineCount, shifted.Length); } - /// Keeps only the first lines. - public LineVisibilityMap Truncate (int lineCount) - { - ArgumentOutOfRangeException.ThrowIfNegative(lineCount); - - lineCount = Math.Min(lineCount, LineCount); - var kept = Array.BinarySearch(_store.Items, 0, HiddenCount, lineCount); - kept = kept >= 0 ? kept : ~kept; - - var items = _store.Items.AsSpan(0, kept).ToArray(); - return new LineVisibilityMap(new HiddenLineStore(items, kept, lineCount), lineCount, kept); - } - /// /// True when this map extends without changing any of its lines, so rows that /// displays keep their row index. diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs index e285a0f3..b365ccac 100644 --- a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs @@ -25,6 +25,7 @@ public sealed class LineVisibilityTracker : IDisposable private volatile LineVisibilityMap _map = LineVisibilityMap.Empty; private volatile HighlightEntry[]? _pendingRules; + private volatile bool _loadPending; private HighlightEntry[] _rules = []; private CancellationTokenSource? _scanCts; private Task _scanTask = Task.CompletedTask; @@ -49,6 +50,9 @@ public LineVisibilityTracker (Func getLine) public bool IsScanning => _pendingRules != null; + /// The scan started by has not finished: the map does not reflect the rules yet. + public bool IsLoadPending => _loadPending; + /// New content was loaded: every line is visible until the scan with completes. public void Load (int lineCount, IEnumerable entries) { @@ -62,6 +66,7 @@ public void Load (int lineCount, IEnumerable entries) SetStateLocked(LineVisibilityMap.Identity(lineCount), []); StartOrStopScanLocked(rules); + _loadPending = rules.Length > 0; } } @@ -94,40 +99,7 @@ public void Rebuild (IEnumerable entries) /// Tail path: evaluates lines appended up to and returns the current map. public LineVisibilityMap Extend (int lineCount) { - while (true) - { - LineVisibilityMap from; - HighlightEntry[] rules; - int version; - lock (_lock) - { - if (_disposed || lineCount <= _map.LineCount) - { - return _map; - } - - (from, rules, version) = (_map, _rules, _version); - } - - var (map, error) = TryEvaluate(from, rules, lineCount); - lock (_lock) - { - if (_disposed) - { - return _map; - } - - if (version != _version) - { - continue; - } - - SetStateLocked(map, error == null ? rules : []); - } - - RaiseFailure(map, error); - return map; - } + return EvaluateAndCommit(lineCount, replace: false); } /// Tail path, rollover: the first lines were dropped. @@ -147,22 +119,32 @@ public LineVisibilityMap Shift (int offset) /// Tail path, truncation: the content was replaced and is re-evaluated up to . public LineVisibilityMap Replace (int lineCount) + { + return EvaluateAndCommit(lineCount, replace: true); + } + + /// + /// Evaluates against a snapshot outside the lock, then commits only if the state is unchanged, retrying otherwise. + /// re-evaluates from the first line and restarts a pending scan on the new content. + /// + private LineVisibilityMap EvaluateAndCommit (int lineCount, bool replace) { while (true) { + LineVisibilityMap from; HighlightEntry[] rules; int version; lock (_lock) { - if (_disposed) + if (_disposed || (!replace && lineCount <= _map.LineCount)) { return _map; } - (rules, version) = (_rules, _version); + (from, rules, version) = (replace ? LineVisibilityMap.Empty : _map, _rules, _version); } - var (map, error) = TryEvaluate(LineVisibilityMap.Empty, rules, lineCount); + var (map, error) = TryEvaluate(from, rules, lineCount); lock (_lock) { if (_disposed) @@ -176,10 +158,17 @@ public LineVisibilityMap Replace (int lineCount) } SetStateLocked(map, error == null ? rules : []); - RestartPendingScanLocked(); + if (replace) + { + RestartPendingScanLocked(); + } + } + + if (error != null) + { + Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error)); } - RaiseFailure(map, error); return map; } } @@ -220,6 +209,7 @@ public void Dispose () _generation++; _version++; _pendingRules = null; + _loadPending = false; _scanCts?.Cancel(); _scanCts?.Dispose(); _scanCts = null; @@ -243,6 +233,7 @@ private void StartOrStopScanLocked (HighlightEntry[] rules) if (rules.Length == 0) { + _loadPending = false; return; } @@ -287,6 +278,7 @@ private void Scan (HighlightEntry[] rules, int lineCount, int generation, Cancel { SetStateLocked(map, rules); _pendingRules = null; + _loadPending = false; break; } } @@ -310,6 +302,7 @@ private void Scan (HighlightEntry[] rules, int lineCount, int generation, Cancel map = LineVisibilityMap.Identity(_map.LineCount); SetStateLocked(map, []); _pendingRules = null; + _loadPending = false; error = ex; } } @@ -360,14 +353,6 @@ private bool IsHiddenLine (HighlightEntry[] rules, int lineNum) return line != null && HighlightEvaluator.IsHidden(rules, line); } - private void RaiseFailure (LineVisibilityMap map, Exception? error) - { - if (error != null) - { - Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error)); - } - } - private static HighlightEntry[] Snapshot (IEnumerable entries) { ArgumentNullException.ThrowIfNull(entries); diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs index 51d40b66..b4f0bd14 100644 --- a/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs +++ b/src/LogExpert.Tests/Highlight/LineVisibilityMapTests.cs @@ -127,16 +127,4 @@ public void Shift_DropsLeadingLines_AndRenumbersTheRest () Assert.That(shifted.HiddenCount, Is.EqualTo(2)); Assert.That(shifted.IsAppendOf(map), Is.False); } - - [Test] - public void Truncate_DropsTrailingLines () - { - var map = Map(10, 1, 5, 8); - - var truncated = map.Truncate(6); - - Assert.That(truncated.LineCount, Is.EqualTo(6)); - Assert.That(truncated.HiddenCount, Is.EqualTo(2)); - Assert.That(truncated.VisibleCount, Is.EqualTo(4)); - } } diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs index 03c4aff7..726cab18 100644 --- a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs +++ b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs @@ -80,11 +80,40 @@ public void Load_UntilScanCompletes_EveryLineStaysVisible () Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(3)); } + [Test] + public void Load_IsPendingUntilItsScanCompletes_ButARuleRebuildIsNot () + { + _gate.Reset(); + _tracker.Load(_lines.Count, HideDebug); + + Assert.That(_tracker.IsLoadPending, Is.True); + _gate.Set(); + Idle(); + Assert.That(_tracker.IsLoadPending, Is.False); + + _gate.Reset(); + _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]); + Assert.That(_tracker.IsScanning, Is.True); + Assert.That(_tracker.IsLoadPending, Is.False, "a rule change keeps showing the previous map"); + } + + [Test] + public void Load_RulesRemovedWhilePending_IsNoLongerPending () + { + _gate.Reset(); + _tracker.Load(_lines.Count, HideDebug); + + _tracker.Rebuild([]); + + Assert.That(_tracker.IsLoadPending, Is.False); + } + [Test] public void Load_WithoutHideRules_IsImmediatelyComplete () { _tracker.Load(_lines.Count, [new HighlightEntry { SearchText = "DEBUG" }]); + Assert.That(_tracker.IsLoadPending, Is.False); Assert.That(_tracker.IsScanning, Is.False); Assert.That(_tracker.Map.VisibleCount, Is.EqualTo(5)); } diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs index 9cd02705..0bcfd6ca 100644 --- a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs +++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs @@ -4,7 +4,9 @@ using ColumnizerLib; +using LogExpert.Core.Classes.Columnizer; using LogExpert.Core.Classes.Highlight; +using LogExpert.Core.Classes.Persister; using LogExpert.Core.Config; using LogExpert.Core.Entities; using LogExpert.Core.Interfaces; @@ -111,6 +113,67 @@ public void Load_HideRule_RemovesMatchingRows_AndKeepsOriginalLineNumbers () Assert.That(Find(log, "hiddenLinesBar").Visible, Is.True); } + [Test] + public void Load_ShowsNoRowsUntilTheFirstScanHasFinished () + { + File.WriteAllLines(_fileName, Enumerable.Range(0, 20_000).Select(Text)); + _window = new LogTabWindow([_fileName], 1, false, _config.Object) { ShowInTaskbar = false, Opacity = 0 }; + Find(_window, "dockPanel").ShowDocumentIcon = false; + _window.Show(); + PumpUntil(() => _window.CurrentLogWindow != null); + var log = _window.CurrentLogWindow; + var grid = Grid(log); + var largest = 0; + grid.RowsAdded += (_, _) => largest = Math.Max(largest, grid.RowCount); + + PumpUntil(() => grid.RowCount == 10_000 && log.WhenLineVisibilityIdle().IsCompleted); + + Assert.That(largest, Is.EqualTo(10_000), "hidden lines must never be shown while the first scan runs"); + } + + [TestCase(12, 12)] + [TestCase(13, 14)] + public void SavedPosition_IsRestoredOnceTheFirstScanHasFinished (int savedLine, int expectedLine) + { + _settings.Preferences.SaveSessions = true; + _ = Persister.SavePersistenceData(_fileName, new PersistenceData + { + FileName = _fileName, + CurrentLine = savedLine, + FirstDisplayedLine = savedLine, + FollowTail = false, + HighlightGroupName = "hide", + LineCount = LINE_COUNT + }, _settings.Preferences, _directory); + + var log = Open(); + + Assert.That(log.CurrentLineNum, Is.EqualTo(expectedLine)); + Assert.That(log.ShowHiddenLines, Is.False); + } + + [Test] + public void TimeSync_FollowerOnHiddenLine_SelectsTheNearestVisibleLine () + { + var log = OpenTimestamped(); + + _ = log.ScrollToTimestamp(new DateTime(2026, 1, 1, 10, 0, 5), false, false); + + Assert.That(log.ShowHiddenLines, Is.False); + Assert.That(log.CurrentLineNum, Is.EqualTo(6)); + } + + [Test] + public void TimestampNavigation_InTheOriginWindow_RevealsTheHiddenLine () + { + var log = OpenTimestamped(); + + _ = log.ScrollToTimestamp(new DateTime(2026, 1, 1, 10, 0, 5), false, true); + + Assert.That(log.ShowHiddenLines, Is.True); + Assert.That(log.CurrentLineNum, Is.EqualTo(5)); + } + [Test] public void ShowHiddenLines_ShowsEveryLine_WithoutChangingTheRules () { @@ -480,6 +543,15 @@ private LogWindow Open () return log; } + private LogWindow OpenTimestamped () + { + File.WriteAllLines(_fileName, Enumerable.Range(0, LINE_COUNT).Select(i => $"2026-01-01 10:00:{i:D2}.000 {Text(i)}")); + var log = Open(); + log.ForceColumnizer(new TimestampColumnizer()); + WaitForVisibility(log); + return log; + } + private void WaitForVisibility (LogWindow log) { var idle = log.WhenLineVisibilityIdle(); diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs index 286edf53..ef75f5f2 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.LineVisibility.cs @@ -6,7 +6,7 @@ namespace LogExpert.UI.Controls.LogWindow; /// -/// Hide-line highlight rules (#338). The main grid shows the rows of ; everything that talks +/// Hide-line highlight rules. The main grid shows the rows of ; everything that talks /// to the reader, bookmarks, timestamps or sessions uses original logical lines, converted with /// / . The Window Filter grid is unaffected. /// @@ -27,6 +27,9 @@ internal partial class LogWindow private bool _showHiddenLines; private bool _isLoadComplete; + // A saved or reload position (original lines) waiting for the load's first scan. + private (int CurrentLine, int FirstDisplayedLine)? _pendingPosition; + /// Number of lines the active hide rules remove, whether or not the override shows them. internal int HiddenLineCount => _lineVisibility.Map.HiddenCount; @@ -76,6 +79,7 @@ private void InitializeLineVisibility () private void ResetLineVisibility () { _isLoadComplete = false; + _pendingPosition = null; _lineVisibility.Load(0, []); _rowMap = LineVisibilityMap.Empty; _appliedTrackedMap = _lineVisibility.Map; @@ -97,7 +101,7 @@ private void UpdateTopRowHeight () private void UpdateHiddenLinesBar () { - var hidden = _lineVisibility.Map.HiddenCount; + var hidden = HiddenLineCount; _hiddenLinesLabel.Text = string.Format(CultureInfo.CurrentCulture, Resources.LogWindow_UI_Label_HiddenLines, hidden); _showHiddenLinesCheckBox.Checked = _showHiddenLines; @@ -125,7 +129,7 @@ private void RebuildLineVisibility () } } - private void OnLineVisibilityChanged (object sender, LineVisibilityChangedEventArgs e) + private void OnLineVisibilityChanged (object? sender, LineVisibilityChangedEventArgs e) { if (_isClosing || IsDisposed || Disposing) { @@ -162,17 +166,23 @@ protected override void OnHandleCreated (EventArgs e) } /// - /// The map the grid should display for : the tracked map itself, or, with the - /// override on, an identity map that keeps growing in place so tail appends stay appends. + /// The map the grid should display for : no rows while the load's first scan runs, the + /// tracked map itself, or, with the override on, an identity map that keeps growing in place so tail appends + /// stay appends. /// private LineVisibilityMap EffectiveMap (LineVisibilityMap tracked) { + var current = _rowMap; if (!_showHiddenLines) { - return tracked; + if (!_lineVisibility.IsLoadPending) + { + return tracked; + } + + return current.LineCount == 0 ? current : LineVisibilityMap.Empty; } - var current = _rowMap; return current.HiddenCount == 0 && current.LineCount <= tracked.LineCount && tracked.IsAppendOf(_appliedTrackedMap) ? current.Append(tracked.LineCount, []) : LineVisibilityMap.Identity(tracked.LineCount); @@ -190,24 +200,67 @@ private void ApplyLineVisibility () return; } + if (PublishTrackedMap(0) && _guiStateArgs.FollowTail && dataGridView.RowCount > 0) + { + _columnCache.MarkPrefetchStale(); + dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1; + } + + dataGridView.Invalidate(); + UpdateLineNavigationReadiness(); + } + + /// Switches the grid to the tracker's current map; returns whether the displayed map changed. + private bool PublishTrackedMap (int rolloverOffset) + { var tracked = _lineVisibility.Map; var newMap = EffectiveMap(tracked); _appliedTrackedMap = tracked; - if (!ReferenceEquals(newMap, _rowMap)) + var changed = !ReferenceEquals(newMap, _rowMap); + if (changed) { - SetRowMap(newMap, 0); - if (_guiStateArgs.FollowTail && dataGridView.RowCount > 0) - { - _columnCache.MarkPrefetchStale(); - dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1; - } + SetRowMap(newMap, rolloverOffset); + } - dataGridView.Invalidate(); + if (_pendingPosition is { } position && !_lineVisibility.IsLoadPending) + { + _pendingPosition = null; + ApplyPosition(position.CurrentLine, position.FirstDisplayedLine); } UpdateHiddenLinesBar(); - UpdateLineNavigationReadiness(); + return changed; + } + + /// + /// Restores a saved or reload position (original lines; a hidden one resolves to the nearest visible line), + /// deferred until the load's first scan has published the rows. + /// + private void RestorePosition (int currentLine, int firstDisplayedLine) + { + if (_lineVisibility.IsLoadPending && !_showHiddenLines) + { + _pendingPosition = (currentLine, firstDisplayedLine); + return; + } + + ApplyPosition(currentLine, firstDisplayedLine); + } + + private void ApplyPosition (int currentLine, int firstDisplayedLine) + { + var currentRow = currentLine >= 0 ? _rowMap.NearestRow(currentLine) : -1; + if (currentRow >= 0) + { + SelectRow(currentRow, false, true); + } + + var firstRow = firstDisplayedLine >= 0 ? _rowMap.NearestRow(firstDisplayedLine) : -1; + if (firstRow >= 0) + { + dataGridView.FirstDisplayedScrollingRowIndex = firstRow; + } } /// @@ -275,9 +328,6 @@ private int LineToRow (int line) return _rowMap.LineToRow(line); } - /// Original line of the current main-grid row, or -1. - private int CurrentLogicalLine => RowToLine(dataGridView.CurrentCellAddress.Y); - /// /// Explicit navigation to an original line: a line beyond the end resolves to the last row; a hidden line turns /// on "Show hidden lines" so that exact line can be selected. Returns the row, or -1. diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index c034d0ee..82dc995a 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -705,7 +705,7 @@ private void OnButtonSizeChanged (object sender, EventArgs e) [SupportedOSPlatform("windows")] internal void DumpBufferInfo () { - _logFileReader.LogBufferInfoForLine(CurrentLogicalLine); + _logFileReader.LogBufferInfoForLine(CurrentLineNum); } internal void DumpBufferDiagnostic () @@ -1665,7 +1665,7 @@ private void OnScrollAllTabsToTimestampToolStripMenuItemClick (object sender, Ev { if (CurrentColumnizer.IsTimeshiftImplemented()) { - var currentLine = CurrentLogicalLine; + var currentLine = CurrentLineNum; if (currentLine > 0) { var (timeStamp, _) = GetTimestampForLine(currentLine, false); @@ -2671,26 +2671,15 @@ private void LoadPersistenceData () _rowHeightList = snapshot.RowHeightList; try { - // Saved positions are original lines; a hidden one resolves to the nearest visible line. - if (snapshot.CurrentLine >= 0 && snapshot.CurrentLine < _logFileReader.LineCount) - { - SelectNearestLine(snapshot.CurrentLine, false, true); - } - else - { - if (dataGridView.RowCount > 0) - { - dataGridView.FirstDisplayedScrollingRowIndex = dataGridView.RowCount - 1; - SelectRow(dataGridView.RowCount - 1, false, true); - } - } - - if (snapshot.FirstDisplayedLine >= 0 && - snapshot.FirstDisplayedLine < _logFileReader.LineCount && - _rowMap.NearestRow(snapshot.FirstDisplayedLine) >= 0) - { - dataGridView.FirstDisplayedScrollingRowIndex = _rowMap.NearestRow(snapshot.FirstDisplayedLine); - } + // Saved positions are original lines; without a valid one, the last line is selected. + var lineCount = _logFileReader.LineCount; + var currentLine = snapshot.CurrentLine >= 0 && snapshot.CurrentLine < lineCount + ? snapshot.CurrentLine + : lineCount - 1; + var firstLine = snapshot.FirstDisplayedLine >= 0 && snapshot.FirstDisplayedLine < lineCount + ? snapshot.FirstDisplayedLine + : currentLine; + RestorePosition(currentLine, firstLine); // Applied once, here (was double-applied: always pre-load, here only when true). // Applying true after positioning keeps the jump-to-tail semantics. @@ -2824,22 +2813,11 @@ private void EnterLoadFileStatus () [SupportedOSPlatform("windows")] private void PositionAfterReload (ReloadMemento reloadMemento) { - // The memento holds original lines; hidden ones resolve to the nearest visible row. - var currentRow = _reloadMemento.CurrentLine >= 0 && _reloadMemento.CurrentLine < _rowMap.LineCount - ? _rowMap.NearestRow(_reloadMemento.CurrentLine) - : -1; - if (currentRow >= 0) - { - dataGridView.CurrentCell = dataGridView.Rows[currentRow].Cells[0]; - } - - var firstRow = _reloadMemento.FirstDisplayedLine >= 0 && _reloadMemento.FirstDisplayedLine < _rowMap.LineCount - ? _rowMap.NearestRow(_reloadMemento.FirstDisplayedLine) - : -1; - if (firstRow >= 0) - { - dataGridView.FirstDisplayedScrollingRowIndex = firstRow; - } + // The memento holds original lines; ones beyond the reloaded content are ignored. + var lineCount = _logFileReader.LineCount; + RestorePosition( + _reloadMemento.CurrentLine < lineCount ? _reloadMemento.CurrentLine : -1, + _reloadMemento.FirstDisplayedLine < lineCount ? _reloadMemento.FirstDisplayedLine : -1); } [SupportedOSPlatform("windows")] @@ -3157,15 +3135,7 @@ private void UpdateGrid (LogEventArgs logEventArgs) // The engine thread already applied this event to the tracker (OnRolloverShift / OnTailLines). An append // only grows the rows; a rollover or truncation rebuilds them and, without follow-tail, keeps the // selected and first displayed original lines (moved up by the rollover offset). - var tracked = _lineVisibility.Map; - var newMap = EffectiveMap(tracked); - _appliedTrackedMap = tracked; - if (!ReferenceEquals(newMap, _rowMap)) - { - SetRowMap(newMap, logEventArgs.IsRollover ? logEventArgs.RolloverOffset : 0); - } - - UpdateHiddenLinesBar(); + _ = PublishTrackedMap(logEventArgs.IsRollover ? logEventArgs.RolloverOffset : 0); _statusEventArgs.LineCount = logEventArgs.LineCount; StatusLineFileSize(logEventArgs.FileSize); @@ -3983,15 +3953,15 @@ private void SyncTimestampDisplayWorker () // show time difference between 2 selected lines if (dataGridView.SelectedRows.Count == 2) { - var row1 = RowToLine(dataGridView.SelectedRows[0].Index); - var row2 = RowToLine(dataGridView.SelectedRows[1].Index); - if (row1 > row2) + var line1 = RowToLine(dataGridView.SelectedRows[0].Index); + var line2 = RowToLine(dataGridView.SelectedRows[1].Index); + if (line1 > line2) { - (row2, row1) = (row1, row2); + (line2, line1) = (line1, line2); } - var (timeStamp1, _) = GetTimestampForLine(row1, false); - var (timeStamp2, _) = GetTimestampForLine(row2, false); + var (timeStamp1, _) = GetTimestampForLine(line1, false); + var (timeStamp2, _) = GetTimestampForLine(line2, false); //TimeSpan span = TimeSpan.FromTicks(timeStamp2.Ticks - timeStamp1.Ticks); var diff = timeStamp1.Ticks > timeStamp2.Ticks ? new DateTime(timeStamp1.Ticks - timeStamp2.Ticks) @@ -4096,7 +4066,6 @@ private void ResetProgressBar () [SupportedOSPlatform("windows")] private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll) { - _shouldCallTimeSync = triggerSyncCall; SelectRow(RevealLine(lineNum), triggerSyncCall, shouldScroll); } @@ -4104,7 +4073,6 @@ private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll) [SupportedOSPlatform("windows")] private void SelectNearestLine (int lineNum, bool triggerSyncCall, bool shouldScroll) { - _shouldCallTimeSync = triggerSyncCall; SelectRow(lineNum < 0 ? -1 : _rowMap.NearestRow(lineNum), triggerSyncCall, shouldScroll); } @@ -4185,7 +4153,7 @@ private void UpdateEditColumnDisplay (DataGridViewTextBoxEditingControl editCont [SupportedOSPlatform("windows")] private void SelectPrevHighlightLine () { - var lineNum = CurrentLogicalLine; + var lineNum = CurrentLineNum; while (lineNum > 0) { lineNum--; @@ -4205,7 +4173,7 @@ private void SelectPrevHighlightLine () [SupportedOSPlatform("windows")] private void SelectNextHighlightLine () { - var lineNum = CurrentLogicalLine; + var lineNum = CurrentLineNum; while (lineNum < _logFileReader.LineCount) { lineNum++; @@ -5488,7 +5456,7 @@ private void AddBookmarkAtLineSilently (int lineNum) [SupportedOSPlatform("windows")] private void AddBookmarkAndEditComment () { - var lineNum = CurrentLogicalLine; + var lineNum = CurrentLineNum; if (!_bookmarkProvider.IsBookmarkAtLine(lineNum)) { ToggleBookmark(); @@ -5500,7 +5468,7 @@ private void AddBookmarkAndEditComment () [SupportedOSPlatform("windows")] private void AddBookmarkComment (string text) { - var lineNum = CurrentLogicalLine; + var lineNum = CurrentLineNum; Bookmark bookmark; if (!_bookmarkProvider.IsBookmarkAtLine(lineNum)) @@ -5524,7 +5492,7 @@ private void MarkCurrentFilterRange () _filterParams.RangeSearchText = filterRangeComboBox.Text; ColumnizerCallback callback = new(this); RangeFinder rangeFinder = new(_filterParams, callback); - var range = rangeFinder.FindRange(CurrentLogicalLine); + var range = rangeFinder.FindRange(CurrentLineNum); if (range != null) { SetCellSelectionMode(false); @@ -5631,7 +5599,7 @@ private void AddSlaveToTimesync (LogWindow slave) TimeSyncList = slave.TimeSyncList; } - var currentLineNum = CurrentLogicalLine; + var currentLineNum = CurrentLineNum; var (timeStamp, _) = GetTimestampForLine(currentLineNum, true); if (!timeStamp.Equals(DateTime.MinValue) && !_shouldTimestampDisplaySyncingCancel) { @@ -5968,7 +5936,7 @@ public SessionSnapshot GatherSessionSnapshot () MultiFile = IsMultiFile, MultiFilePattern = _multiFileOptions.FormatPattern, MultiFileMaxDays = _multiFileOptions.MaxDayTry, - CurrentLine = CurrentLogicalLine, + CurrentLine = CurrentLineNum, FirstDisplayedLine = RowToLine(dataGridView.FirstDisplayedScrollingRowIndex), FilterVisible = !splitContainerLogWindow.Panel2Collapsed, FilterAdvanced = !advancedFilterSplitContainer.Panel1Collapsed, @@ -6028,6 +5996,7 @@ public void CloseLogWindow () CancelPendingLineNavigation(); CancelHighlightBookmarkScan(); + _lineVisibility.Changed -= OnLineVisibilityChanged; _lineVisibility.Dispose(); StopTimespreadThread(); StopTimestampSyncThread(); @@ -6294,11 +6263,11 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi return; } - rowIndex = isFilteredGridView + var lineNum = isFilteredGridView ? _filterResultList[rowIndex] : RowToLine(rowIndex); - if (rowIndex < 0) + if (lineNum < 0) { e.Handled = false; return; @@ -6319,8 +6288,8 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi // Use only prefetched (pinned) data — no unprotected fallback ILogLineMemory line = !isFilteredGridView - ? _columnCache.GetPrefetchedLine(rowIndex) - : _filterColumnCache.GetPrefetchedLine(rowIndex); + ? _columnCache.GetPrefetchedLine(lineNum) + : _filterColumnCache.GetPrefetchedLine(lineNum); if (line == null) { @@ -6330,13 +6299,13 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi // very small files with only one visible row), and PrefetchVisibleLines // therefore short-circuits without pinning anything. var targetCache = !isFilteredGridView ? _columnCache : _filterColumnCache; - targetCache.Prefetch(_logFileReader, rowIndex, 1); - line = targetCache.GetPrefetchedLine(rowIndex); + targetCache.Prefetch(_logFileReader, lineNum, 1); + line = targetCache.GetPrefetchedLine(lineNum); } if (line == null) { - _logger.Warn("CellPainting: null line for rowIndex={0}, isFilteredGridView={1}", rowIndex, isFilteredGridView); + _logger.Warn("CellPainting: null line for lineNum={0}, isFilteredGridView={1}", lineNum, isFilteredGridView); // Paint an empty cell with proper colors to prevent white-on-white default rendering e.Graphics.SetClip(e.CellBounds); @@ -6374,13 +6343,13 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi if (columnIndex == 0) { - if (_bookmarkProvider.IsBookmarkAtLine(rowIndex)) + if (_bookmarkProvider.IsBookmarkAtLine(lineNum)) { //keeping this comment, because it's the original code // = new Rectangle(e.CellBounds.Left + 2, e.CellBounds.Top + 2, 6, 6); var rect = e.CellBounds; rect.Inflate(-2, -2); - var bookmark = _bookmarkProvider.GetBookmarkForLine(rowIndex); + var bookmark = _bookmarkProvider.GetBookmarkForLine(lineNum); var bookmarkColor = bookmark.IsAutoGenerated ? AutoBookmarkColor : BookmarkColor; using var brush = new SolidBrush(bookmarkColor); e.Graphics.FillRectangle(brush, rect); @@ -6578,8 +6547,8 @@ public void StartSearch () var searchParams = _logWindowCoordinator.SearchParams; searchParams.CurrentLine = LogSearcher.ResolveDirection(searchParams) == SearchDirection.Forward - ? CurrentLogicalLine + 1 - : CurrentLogicalLine - 1; + ? CurrentLineNum + 1 + : CurrentLineNum - 1; _currentSearchParams = searchParams; // remember for async "not found" messages TrackMarkerSearch(searchParams); @@ -6737,7 +6706,7 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) } case Keys.Down when e.Modifiers == Keys.Alt: { - var newLine = _logFileReader.GetNextMultiFileLine(CurrentLogicalLine); + var newLine = _logFileReader.GetNextMultiFileLine(CurrentLineNum); if (newLine != -1) { @@ -6750,7 +6719,7 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) } case Keys.Up when e.Modifiers == Keys.Alt: { - var newLine = _logFileReader.GetPrevMultiFileLine(CurrentLogicalLine); + var newLine = _logFileReader.GetPrevMultiFileLine(CurrentLineNum); if (newLine != -1) { @@ -6904,7 +6873,7 @@ public void ToggleBookmark () return; } - lineNum = CurrentLogicalLine; + lineNum = CurrentLineNum; } ToggleBookmark(lineNum); @@ -7197,7 +7166,7 @@ public void JumpNextBookmark () } else { - var index = FindNextBookmarkIndex(CurrentLogicalLine); + var index = FindNextBookmarkIndex(CurrentLineNum); if (index > _bookmarkProvider.Bookmarks.Count - 1) { index = 0; @@ -7256,7 +7225,7 @@ public void JumpPrevBookmark () } else { - var index = FindPrevBookmarkIndex(CurrentLogicalLine); + var index = FindPrevBookmarkIndex(CurrentLineNum); if (index < 0) { index = _bookmarkProvider.Bookmarks.Count - 1; @@ -7473,7 +7442,7 @@ public void Reload () _reloadMemento = new ReloadMemento { - CurrentLine = CurrentLogicalLine, + CurrentLine = CurrentLineNum, FirstDisplayedLine = RowToLine(dataGridView.FirstDisplayedScrollingRowIndex) }; @@ -7578,7 +7547,7 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo } //this.Cursor = Cursors.WaitCursor; - var currentLine = CurrentLogicalLine; + var currentLine = CurrentLineNum; if (currentLine < 0) { currentLine = 0; @@ -7587,7 +7556,9 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo var foundLine = _timestampLocator.FindLine(timestamp, currentLine, _logFileReader.LineCount, roundToSeconds, _cts.Token); if (foundLine >= 0) { - SelectAndEnsureVisible(foundLine, triggerSyncCall); + // The window the user navigates in reveals a hidden line; windows following a time sync select the + // nearest visible line instead. + SelectAndEnsureVisibleRow(triggerSyncCall ? RevealLine(foundLine) : _rowMap.NearestRow(foundLine), triggerSyncCall); hasScrolled = true; } From 12102287c61a639c2ce5065f5dcab64118b2b8c1 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 26 Sep 2026 21:33:45 +0200 Subject: [PATCH 05/11] Center the hidden-line count beside the Show hidden lines check box (#338) --- Temp/Statusbar.png | Bin 0 -> 18887 bytes .../HiddenLinesWindowTests.cs | 22 ++++++++++++++++++ .../LogWindow/LogWindow.LineVisibility.cs | 4 +++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 Temp/Statusbar.png diff --git a/Temp/Statusbar.png b/Temp/Statusbar.png new file mode 100644 index 0000000000000000000000000000000000000000..c254b0d70ab974a8f0a218f06beb1262d3bda76e GIT binary patch literal 18887 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

+;Uw}%y%p}p^Y`1;CL!vx zYFOcc{e|6Q##SU_gvg?f<0jIj`2b0KAAIwyj#Wq<&sS+om_q=<` zL=BRwi6Dpy`el*KIES1ln(3~+qoSfVQ_Zu6kuj_SKJxPBPCbTaz3jr-Y&1%#V&zRqVZ`CD&E2{azMa?DHr0*$3=0_wwr9LFdPM(wxi)wf3I zH)3e){Ft22&^BoI=uo^sG2Kmw{RVpreC~g>DO*n^9qtwXEi~c=a#enlS*h&G4{Z<& zZe`EK~#uZN8)?G2?wXHaV8do$E-JJ3&Ah-yg{z7&mPY!oiFKoIde>U z?SCHLlGOD|y}VfyzOQ!1nlxp8EDc+9`f9dI+5=zn6;F3P(8S* zpxpJ|<&(H^rnl@3L1~2qR}DF!Pq3am1=Y}_pSHCvoVwA&0BypOL3XR&nkImLjA zmX?-)0QwtFH3%AN&c*cT(9>ympJow3teY$E#3~JBxIv@j=HRdhpszcF_`NQ#&U>-Z z?j+|O4=H&f)_adXnnWI3_zwGn(_hZmOzJC^ZJT|tSQ74WUmt2^n|;z(i7y*xq|1tY~tPq8oL=? zV_0wE*rcdN*f!bs)Y4>iMe@a#t%a;`6ZiVuNp)y>Y?o`H9RbEDRVKgKd!1z0*AEu( znbb4LuS|L3U2Mm(1{zr48h;jsKeK9R3bQ=za5p<)`ZxF)e-=;L)MRP0+jHY-z4%ay zjuT!p9^wMW32VV(QzMNjdY)45eKY3RCP_!FsuK~5gx1_Rvqxq9Pr z&1l@^&=aTZUJl@`&$wRICS0RsYp(oU#Qg9U(6qD{(Efb#>lYutekS?Ufi-y6rs)an z%#3Z!Tn&_sv7MaTB=mE|%r|92C{Jv35scLKd*-c}Wn8I2&3>xo$z2=5IKd2ojroq% zHf6N?Q*l^D6fOC9UR|G)n5Faas2pnk{6W*@W?p?8f{q5?Klx!PrI}sqSc(mK8G^;Jx#!y%DnmQqjkIstI4P>GXvJ@Q6iU` zGTLV%-$Wi}JEoz*x97}zxqm)eW@QGYDj$zQ)gU{8I;4~afwDE~HtQ2cqDAUT$$7TQ zH?^E4*I|ZRJH61!7mbGDf9b&krbOLUqgpwoN!`Y!*K(PB!D(q}V;h(5M1Gfee!0WS zcE52AIv~%`d^tdP2?3t}I?jEVv%JoDS+%8TU38cdom*T_wPb{6l@D5qSDoOvBj)#7 zjtu6Rece6)f##0B>-Q@uYL!);BghU3fF&Ir82s)nMq2;dH zczeAof?DX0z`Hzi>(pMgIlAk76C`8)SMvN8LTK0Zj+y+h7|re99+@wm5MEVQkrAuY zjYd=v_dVakP1`@8+fGIMb?t)TIoMKdUN&|nD5(3m6*^BpkO4td&fYFxQfR%{om=*TRm^> zBCFy%fAI9&e~AU{lqeU=Z4MCD>rZ$bl@`~Sj^aXY-wTKc3nS|PuVE!)NctBlwK+XT zqK=;*u8?cO!iHEoALpwt*6)CB-NK=wuz(aM-JYvS;h4MjeZf@QX$$P}@$oe8-T!QF z@g6mR^u#FQx{j+mna(KcPJh!^`44V)L%ik(fJ2LpQF2`$xhMtX6WTJ7W%^$kG$qsQ z*Z+sa`hVuxc(42N4~(J|SmvG0a;uOF67jqs@f8A8OxPP#ls5*Js2`d@b0 zrsHe3Y?<24Z*ZC))>JRro#7T#bg9jt02QUJ$XRYxUE}9fJzi@1zi{lg7s((REgws^ z)Cjz+2IYL%NB1TR7IEYTtm*__(}2RXqU2gYDjv&TaIW< z6x?d3u3dm5E<*hRXnKL|luEZ|eHY{YI2~jlb4oXs^#YT{ z!x>s^#vHa(Q|O9gr>`Hi>{ijGT#t9Lmaui>8k(xeSMt#_SRNz*LMs83M;GWaf8TS;^-)=HN*Svd_(pUjNqxd%4UG!)g7qe zUe&=~)Nml-Oc)zDjU|8x@=P+l&&seZEbCA_jFE0zKBdUQ@1~ZEs&b}5ng#Xk$?D3U zLhhOS*HYf+6K*_5yI00{g^@O&(RmyBaH{E@_4omKV$QSEdhMBkzl7UU&Hhu`80Hth zpNL@2F`ciTt~&x<6cqYZzUqG%t0)z{|7;yAkR2Eg>?PLw)(z4r1aJ~SkF9Ryt~V^3Ir1PJgg-Cjof#uN-FJ&#}F@tc?M;E8+q~l+o z#Px9RnDv!uCJ#fQ_0uGw{mpHY+H*hJ9;-vM?+20ectmEj!p>ESe69Dn{L#q~q7M%%;Q_;IbhaHyMCvw^7~@q* z_E5c8-@LsaspU}yrh~d(z`T*|u>W>=#cMGcx#{iqa4U2_G&M68dj$0eYhT3X%pny? z`+dG%t>{4gaPu15XwG!O4Q@=gW_#)#;ZhZn!s#J5)Q8$V9&IqN#Ss8trskMXE$G5f z`gutWA^zy#V1}zn4%FS3?W9C`6Xf)QYSQtddVSpEY~Ed_znnXP^M0En3Z$o?pet*K zT7cLYDRZlEG~OEN;JH=P@?+=CgSz#U^NKX3Jgp$j{?yYoRkMBDf)jHXB$@Vi=CR?h zFaJ_tVE!)+Z`1Fexr5*Zdo#&*^0EnvNtE7McJS*baR)}~ezKD^Yu1NXXQiwQdvwn; zA~NJbC~0O=^cQsx5|v-O$DYLu7i;bCb}mmx&{g280n*v~HEa8=C}C+a|E&#(OX!b> zOeQvWwFp+hrbsZyP7{2kQLN$49>I_GM$GB+-Snw5%2EUxtP=oWBA1t)wDjYqycE`X zeTT^v;>$5_V2jbR#}A$d3JPWz*>e!Qv=Z}1#DQRVaFTt>DsZ@1U#IU@_fLbmezf+w zZg6MwD_y(yC5ph^jDQ~S9{zRfvD{&_t-R*;XtX~R9pXZY7@#YSef8r9caf;6yP%(^p^I9?B!MXG)Tlg5~y z54b8omtUO^fSGI^WyOse9c7nKTsqo?sm`3PkL_l13nA9!KS09}pQ=^DhFG(Z(;!DO zpfpLP1Y9dVj{4QU5YNVN2T`}iGZj=KuTJ+T)fyVqaVU$&0v5^Rb!*0PoY|Xy5UtAc zg!+;X(f)mYUNCq*Chhu~Ik%96sRPx8P}TO)?*?c!o>6|DB~YB2aHD=+J^(o??fnHp zX!tBmVqgk9%Knh7VtF3%eL3?4I0XMDr3z{#Sfd0V+#yFFLm_q8GQUXF>wNq7VvP)} zZSn^@$Ag+YAHLm>2dKA%vOZPzFl;Q-`NZr{Xh0sXajvQd;(YRtobd9@C$`&YCO4b# zHUNO<+bzQTvtj43EZ&&1QQiB#M=P6BUAS8lh#0fdLW6bUr^Pjtyh$Z1B^J zC6qyNYzT~fOFKCOniXEyep?C{<=zKY%B|Ryf$Oo(CvzWuVds^({?YN1(}2qg3$u6_ ze$oySABFdeh=q8`c;T+ceuj(Drh>O>cclA30rJI>H~VJf`&q5SH}T!*cQy+zrJw&z zK6_V;5Y@|D2q{$ObI#41-vxgc0{sn&^ec5{v!de z4g*R6X@K&E2Gv#h_j9=%QUAAe8(dt2!rRYt8c|19w45PE-z{Ca&2BA~wtZ!R-n%== zJ+A{+DxpSOs0Fv7Q-3_xC4z4E>E_#s7fUwir@eT-RII5Q>N{^p7Y;$Awqq^K z>&d~uEfAYwp5x!T5Hkx$^5OU&!AxPJTBYdHIo}hu$lpebE5xBq=KxpkpkAasu84(SSKT1GW5oO6$Y^_~6$OG*lgcfJq{7p!}e{d_yd+(}l~ zst!I%nQe*lzI%O*Vzk`HCpQ-uAB5?B95*Ufn4AgQ*D+1r5l!S$e4|=?Biik(hTjb; zF%c*9Jq-@GCy+g}CNwmG3xE)$in1aXBmBxUktIlvD+58JJ<_ClQ_O1uLFuS{7vz%XJhTvuJS31_sV}{K-!$sU2>(;q7*3XdghWkNo=)X(%i(O{FPjTP-)I2Y`5jG+H zDKRwGA&R^_T86GP!FP5|W}AO9449}`sLSDO&h;-*?7m*OhfPvG1=^tt(HLknsc@_o ze7lzJ^8;#EgKI9z_wT>1RieGU=zKJ>#TFUPR1c6G@;q_hY9DORCJQz7oYi+%wuu@6)!l~Xni6)KvA|F2rhFI4lWX)z?aszj;&8{a>$hS$eYAAKz z5^-|VJsa;U0_w!Lp^@7}GGJ@%K%UMN=W)5li4og1id4uOUshIjvC)>WtgNhW%>zIcwMcd@xc!+LJ4;#V=hSR8v4#aBZmBT+u5zaN+Pxzv#P)~@3z^~#_k@z`2 zoR&mmWY^9Bb zXnM$o3?VHcVcj3VelG>KqZ%$9lN??3?AyZ-7Ker_l8xY*$UjIj8w3ZOasfe%hDUy zoH7!nCjeo_C4P9Wr%Hvdie1NW@w7Q++^u}=VQ8m)14mXW++2CAe@UO5qcX9-m&M?5 z@8VN6WI?3nt-PgnhZ>_)1`tfmywjV^JU_-nL%3PV&|Zd-nwCg33=3D-frZwFP#|}5 zLG?UHaeHiXG9o=nlVF4)g^JyvmPtZv#?vD-fN{?;M8o{jJ`b#@;a}*X%#MMS+uBON ztG}T(GOe(uDCI0wK)5Dh=@Fcp*>hEhj-+b5A1l;cr!H6PKJ8`zjfM4yo$R$l(1SHo zGu0h7m01X>$PJO_iqckow#&c1Tn!1 z9gZ|+jn<)TZHgbA8bPQc(OCK#;BW9GzrjZ9Z{^sI4X2S*8_Hh%foIZR(-#GQ4JIOnp7MWy?9xP@KNsV0R9jza&+w84gb z{yCTmIq!SxUdGP-)fhI9w01-~2Wb%UM8|h^H>u&O%V0JgJ2ON3vr-BMeMQ%i7yQu< zUzO>P$Ta|tAjf9|(jy6OX)zl@Nkx#G&J{{w+8P$mx_anz)C|k&K<1->+DNU;5mW*Y zr9qL?TvWOT)F+b%--bpcj?h|L+lrB^fEX1uzsS4uZNu?FTN}lYNO$3Vg)USG?8>C##-DugmZcr`xCnkQ5(Er|NhV8TGex?=1%qLN>y;=={%M2HK( zN9^uZX2;WPwDVIqS7npP7oeRy!E@QKM=mU=Ir@2gKb73p&w;fiNq(|YqN`%Qo%F08qQEHg9n{;BiL z%dws;Si>TPBU^d%CePRl?`$5KZ~(n*{X^;1bB;j47ySr@iIj}u?VK_<-E@Q}!rEHG z1AZa$o9j_w8r)FsHd)o^OHR8&raeDW0Wu$FPMnn(UH&!!b6i zn41jyBl)}{-NJR0vMull6!WG0f~4YGLeHyiehGc~B{af3M^0{TcN}+Cozl#9TK^%> zRBZ_;vc0BiQz1S9_t=9-WyZSu1LxB4{UBOibv2A$AM0F)@=+#MTdWgfDh6RMdVw|j z3c*q-rkC-fy(0aUT8G{|CS{qE6GX#tNv0wU`@{9(p*?ahCqt@LNslB0q`a}x2b>mMV=7UCpXqsxu`Du}U&t!b)gHsdU zy+1XJWb8{i6&d{=%)fgUr=$=Z_WU|s9~0myB{JVHO)SSC9$kH(mMDg-GwwxhYQgU6d%2_T%&=)l3u7qS|SNwHu>-#_b)Bkk3! zeTn)M7M8J|ja&IVX>=2hb`v9=;*yrT)%xEU$VE+~pl}(9Q4B4Iqr$)aA=9lVG#pnV z#X>aqt?LqrxDhbhYmZ6im=r2!@71W((zrc5lv5k=FUd3BD{`7;Jqz}!bffB# zrG8UFZ3}8<8rrBuAt7^us!Ee;-WEe9nO$%*jFpBpM3N#Nl+jF}I&`%obt#!Jh5xWe z62-*aQ3uf$42%3ytd>%u4oEA-vQIac2~LEjLZeeN7Z*zy^lLHJ4!&+c-PAM+{P^<2 zB*n8)0g@OyYN96;YEI_%sn`zv;E)3XUP8N;LF{H zy+#zcn8eZa5RS+Db0)x_hfHdJ}xm%q(RjoHsTG$qy5 zc-+j8A}$zbD~qaNHRa&Tf6j#zhH!S3vmdcvUpC%|As2A$?eF)md5lj^ekO>bAc(31 zWrh6?5epJmafG17h~7{%V^2@5)g;hJ61^jYwzBEiS2>2BY9RCW#ZUr3Y zhLT`jVV@xB-$TDU5D0l8qAl!H`O^LMdlU!;qx2KJ#@i*>)Cd^heb~&M=y3!zEm;5W)c*<_=n9iQv=sWVl4h~b;b9waDZ&8-&DP-w%Y%jT zzQO@adB34BaBBO8;Cz(%z> z%f@n*Esx4RJ}kzskTJK2lD-5m;pucE=Eadcr#>)`6nai@$%nxjCW7_w#n}C5pT)tS?Kr=$;rdK<|UOP84liWc^{5 zKYI$z_z;&+0V|OFIuECJga{8guSNHUdVW2!`_Zw0hK7}e z#eaC1V*o}O(Ia_g>JQ=Z(Y&7|#&r;ju5ec7aRR>Nivc2uN1fX2SAy^wX7(oQy4jA% zuP_k(Wd47J$Z#-uaDlZy&54B!S>mzF3ewH@;d z#}R&vlR*KdeS?a;5&wF|{-9`x((pKoy!RbB>#|Px}sD;HC$p7m?6f|9aOCCl za?BJq`oW=iLdNjgTzHm07!ct?t(BK7;h!W19^>$*MZRs!@6b*1lmuj>@fZ_Wsr;dw z_z{tw7Y6%sQQrEHju3|;gu;-0s}--$Ge0be^^DMjC~rjVF!jG9Bp$`>WAFX?APK~3AI$rKdAvn z3Dq3P=xKIhlMb%D{*E`rGRmX59m#xIy}xq_s5wYURCa``WnmI#9MXOljRQmEp3n=2 z#;5h)yEA$+pC%0p6+=Ei$c$V98eJYP+?=`f%4t`LBxnHTM{?d-0o*gvq8mllabt*a z6`BkqXjmMHE+Qb`T6Ml)o}w8#IKYRv)qRXlY`YXBCu0%%aEc&$v$I4QA4leiF#m)z zyOi}vwXQb`J!#k<7xDpt)q+g`QA415noNfYFJQSEhr-hx{VSkS7WWDO(NXIoCB}HV zWRG=Rav&R0O~EeooIcWow_x+X^Xt}WUV&(U=tc80B7ES*ldc8HHF*a9*F8NYjO){F z%k%ypx@ef;QphD}MV5f@O8`1}$YW(%X!bW(Zp6mgQlP*6NAehO0+CE0J`q1$#M z>1bakHbs#3RejgwW4=^-;ps4;c^1RqGn2iqEyB!%ZA4@7w9#FwLBpSxG%ebtkeS;l>wTf*XVja*dcYrJ>apiS_&s~$>C@}$OlNdaCfL)kfEWZ>yVPOuu>lzI$0lzba$oDlD?ZHIv2wMUt9C?}Zc!*H>VJd?Q7i)-JfP(o0V=3b;^2 z2wn5P?REWcx@vU*`cEi5%x~=hqq~wUdCzG{dPjT6a2PmO+KlSxQLvkL=A*a! z?va`8sgDRzR%tSr(>cOk6|ZCz)2owM*Ypq0#Pyj@r#lV9Rz3+*r5(0elg~CJ+HAkS zPobrZqK(Si@3XpswM^?zUjjKJ=7j8^+GMe;uQ$Rc0d7h4sbr zG}mGk$YAuVn>{EdIoW~pr=&O~@7l+5+ldqJI~QU0jE&9yJgpoWXNHD+elv{teEJo% zW3pFFr)9p30$!#C$?2ur%yA!F=HON0t4(<2A>dy!Tu>WqFUBjvzWClhO&aK`qy4Dj zW8{yiO#tC~*``F^Z>B2l6HDk;PxIDxPt-0ATc@0QLEE&u-rT{O&l_hho%YvJQk0xH-6Zd{8&4-f(P*R6o`8rGZ#uAAs~>(3sh{`hAM#B-dgzSDS-Z+&}Km@ z$j1^N+n{6D%I6Vy@sl z^YbjS+S_GLAk7Ytu8q>=$OeqaQR|#>bWHOsD)%1Plw?nTbjC)<&dZq^|^eh-ken>kE_=9f63~KR>2V z8fdkB+k-b)#nqzTC8$dP^@$#*M^Y@3Krd@Kbv`WZb=5Kw^jeS$&o)|%s=E_(^z;)6 z3Pd@iXkIHA5KDJW2tsk`wVB-o{oXzkPq#;Zq3%{-+nPjP<3uT7giVwZ|Lhw{hCDk; zQ%pG^Vc_nEP)8a&1Qj@*My}Nep|RxC{FDw@MG0s~8>p9TY{wQMhbJnXWEP!V&8KNWvQ1L}xfN zDl6yYM(1o$Z`o`P-*Ji|(~)W-D+SHKfkMWr7ZYa|b4_7K0_BN`Zx^^ZrJpPyu(&{R z7Q6W6Zj6J4hbPLxi0w&Y%TCl>ome6$#(CjN0>g$x+`v1hD?Q@N@0?EJ&(!vj!S%{^ zXYH~u%Xp=KB1w!>z(nDFJV%lOt%jvN$FWa~$pPNB2!QDv1op~=KN%U~e2g#rk?R7# zEQ4#jCD4lo?rEH%om@>H#$%UW+~$Tq_U-Y85QaTXSShl3Ha*k>k$||F6*U%qVIRp9%i{ zwFzEX3;MOmf_}uUBTd7N=5J}iH2AY>p3nHF$@1DVoAS7`(^!8*zjN+oL`wWS&s?~RT^ertIvhrN+VM5U^8gB5EEy<2Bdcrp#@a-_@Z=~2>6$orAohd1m0_qD+@KuL&fC-&Gr7|i1EkKa)?9sR)V}A z>|%4$xaPiSewgvSV(vfjt-MAl6cVx*-WM(G%ZY}{AJB;gT1fpM_c-Rw4ibD%C5?j+L!#Nz$8$J>1ex;>3=J*L6TqASFM`= z4q|xS1jR9a5=&Tn8hD^=FcCD`JJ$Lr){%=sAW2XgfP=j-$T^Idz0nN#J9-0Lzm>p=c|VLwZ;BE?3QfWv6RO8#tEwFFpD zB6bREHwq6LJMA&%00-j3;9r&o9|Ob0$B%O|@?0`G?r5 zxLfppSK<7yk6r=wN3|0NY9=z681o3CvS5VV@O)?AuZc=~X+udM(z*89qxG>}(L9k& z$226lNA#UZZ*=EwNZtlPs=F~IV8$SuP0x9UZ<4~WxLmqBUcIMg3?Uze@24F1jdk%wK z%m1$BMAkr7*)e-DjL|38k%j~i_2>;}0%XCdT@ldUFAmH?9UNEj`w|3Y4w z*zwqPbqX1F#;;4@7WTS>m7+LFsEW$~1wk^eyBmW+2$s6yTjnJBx=h1mC2D1Ti65z- zfB18;*j^^T9g@wX)SiJG%*FMqW*V0Ru(mhSeGA{ij4ZvZLZ*7>6HJ1IDco5BeiJPC#Oez^+;ev@YS;pE9BSP^(R9y zY<6XdY)pZYUc`a9Y`N1!Ynp0Ct|M)RZ~Tq@6gO$#vk&?sS`{SrVZXlcIYw0AyL>^_ zzJvWP$bL#rCScbU$IvLuI zyIhvLQc_zkvtbS^NOWB0Ey!RGE0+cQf|(fv&4D$esmA&gfeZ-!>2;NqAFwLM@NZm(RGg z2RHMiggH^Pu(RI>RdTp=SWVWqy% zz6PC7TJozkC<4Bvt{%af`X}mK&=dI;GQ?5{*jh+)yegeecWY@%%i&j}%-VL9eFh(s zsg{Fk>M_+dE2h-SWc4@~cop*7ahpDE(LsiWs&mKa@maIgFm%eT z-3~xqY(05270D3IExL-BYvCd7;5JYuw~>qX+7==P;$J;ym5*CrTt5_H%NV%xR-4eY zOZcK!d7W)NpEz;A4oF~Hi?RIi-b%Rv$a~5BBh-l!vW&rT(A3WP{(O5c?_o_Phg`k( zNeaY^NIu41al*T6>kj>Mxcsh(cY5h4JF%FLkO2Cb=;|RS<%pI_J}tZWhA1G%Dq_uH zmmkycZ|2A-hR5_bm>W()0W>x0tWxM;2TLhF`%!`PD4@62TeWBdI=V?InD`MAVJ!AY zELbt!sAniE9QS1 zXLj~acU%V|LDxT!1K!!Xw_3qj1=fUsZk9YNC+Wa@s94>N)7B_elXzxqG{CXDqxFRs oGggKEV~1&h`_}$PR{A>)vPo8t2%Gi+mM=-UyZX4aJB4KY3*Z&;5C8xG literal 0 HcmV?d00001 diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs index 0bcfd6ca..87985173 100644 --- a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs +++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs @@ -174,6 +174,28 @@ public void TimestampNavigation_InTheOriginWindow_RevealsTheHiddenLine () Assert.That(log.CurrentLineNum, Is.EqualTo(5)); } + [Test] + public void NoticeBar_CentersTheCountBesideTheCheckBox () + { + var log = Open(); + var bar = Find(log, "hiddenLinesBar"); + var label = Find

+;Uw}%y%p}p^Y`1;CL!vx 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 diff --git a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs index f29de37a..90daf076 100644 --- a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs +++ b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs @@ -61,14 +61,21 @@ public static IList FindMatchingEntries (IEnumerable /// Returns whether any hide-line entry matches the line. Other matching entries cannot cancel the decision, - /// whatever their order. Temporary search-hit entries never hide. + /// whatever their order. ///

public static bool IsHidden (IEnumerable entries, ITextValueMemory line) { ArgumentNullException.ThrowIfNull(entries); ArgumentNullException.ThrowIfNull(line); - return entries.Any(e => e.IsHideLine && !e.IsSearchHit && IsMatch(e, line)); + return entries.Any(e => IsHideRule(e) && IsMatch(e, line)); + } + + /// Temporary search-hit entries never hide. + public static bool IsHideRule (HighlightEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + return entry.IsHideLine && !entry.IsSearchHit; } /// diff --git a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs index b365ccac..4ad9575e 100644 --- a/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs +++ b/src/LogExpert.Core/Classes/Highlight/LineVisibilityTracker.cs @@ -3,24 +3,15 @@ namespace LogExpert.Core.Classes.Highlight; /// -/// Owns a Log Window's : which original lines the hide-line Highlight Entries of the -/// active Highlight Group remove from the main grid. -/// -/// Full scans (new content, changed rules) run on a cancellable background task against a cloned rule snapshot; the -/// previous map stays current until the scan's result replaces it in one step. Every structural change bumps a -/// generation, so a scan that finishes against outdated content or rules is discarded. Appended tail lines are -/// evaluated synchronously by the caller's thread. Evaluation uses only, -/// so it can never fire a trigger. -/// -/// -/// Lines are never read while the lock is held: the UI thread takes the lock too, and a reader call may wait for the -/// UI thread. Evaluations run against a state snapshot and commit only if the state () is -/// unchanged, retrying otherwise. -/// +/// Keeps a Log Window's up to date with the hide-line rules of its Highlight Group. +/// Lines are never read while the lock is held: a reader call may wait for the UI thread, which takes the lock too. /// public sealed class LineVisibilityTracker : IDisposable { + private const int READ_BATCH_SIZE = 256; + private readonly Func _getLine; + private readonly Func? _pinRange; private readonly Lock _lock = new(); private volatile LineVisibilityMap _map = LineVisibilityMap.Empty; @@ -34,10 +25,12 @@ public sealed class LineVisibilityTracker : IDisposable private bool _disposed; /// Reads an original line; read live, since the reader is replaced on reload. - public LineVisibilityTracker (Func getLine) + /// Pins the buffers of an inclusive line range before it is read, so they can't be evicted mid-read. + public LineVisibilityTracker (Func getLine, Func? pinRange = null) { ArgumentNullException.ThrowIfNull(getLine); _getLine = getLine; + _pinRange = pinRange; } /// @@ -96,14 +89,14 @@ public void Rebuild (IEnumerable entries) } } - /// Tail path: evaluates lines appended up to and returns the current map. - public LineVisibilityMap Extend (int lineCount) + /// Tail path: evaluates lines appended up to . + public void Extend (int lineCount) { - return EvaluateAndCommit(lineCount, replace: false); + EvaluateAndCommit(lineCount, replace: false); } /// Tail path, rollover: the first lines were dropped. - public LineVisibilityMap Shift (int offset) + public void Shift (int offset) { lock (_lock) { @@ -112,22 +105,20 @@ public LineVisibilityMap Shift (int offset) SetStateLocked(_map.Shift(offset), _rules); RestartPendingScanLocked(); } - - return _map; } } /// Tail path, truncation: the content was replaced and is re-evaluated up to . - public LineVisibilityMap Replace (int lineCount) + public void Replace (int lineCount) { - return EvaluateAndCommit(lineCount, replace: true); + EvaluateAndCommit(lineCount, replace: true); } /// /// Evaluates against a snapshot outside the lock, then commits only if the state is unchanged, retrying otherwise. /// re-evaluates from the first line and restarts a pending scan on the new content. /// - private LineVisibilityMap EvaluateAndCommit (int lineCount, bool replace) + private void EvaluateAndCommit (int lineCount, bool replace) { while (true) { @@ -138,7 +129,7 @@ private LineVisibilityMap EvaluateAndCommit (int lineCount, bool replace) { if (_disposed || (!replace && lineCount <= _map.LineCount)) { - return _map; + return; } (from, rules, version) = (replace ? LineVisibilityMap.Empty : _map, _rules, _version); @@ -149,7 +140,7 @@ private LineVisibilityMap EvaluateAndCommit (int lineCount, bool replace) { if (_disposed) { - return _map; + return; } if (version != _version) @@ -169,7 +160,7 @@ private LineVisibilityMap EvaluateAndCommit (int lineCount, bool replace) Changed?.Invoke(this, new LineVisibilityChangedEventArgs(map, error)); } - return map; + return; } } @@ -333,12 +324,17 @@ private LineVisibilityMap Evaluate (LineVisibilityMap from, HighlightEntry[] rul List hidden = []; if (rules.Length > 0) { - for (var i = from.LineCount; i < lineCount; i++) + for (var batchStart = from.LineCount; batchStart < lineCount; batchStart += READ_BATCH_SIZE) { - token.ThrowIfCancellationRequested(); - if (IsHiddenLine(rules, i)) + var batchEnd = Math.Min(batchStart + READ_BATCH_SIZE, lineCount); + using var pin = _pinRange?.Invoke(batchStart, batchEnd - 1); + for (var i = batchStart; i < batchEnd; i++) { - hidden.Add(i); + token.ThrowIfCancellationRequested(); + if (IsHiddenLine(rules, i)) + { + hidden.Add(i); + } } } } @@ -356,7 +352,7 @@ private bool IsHiddenLine (HighlightEntry[] rules, int lineNum) private static HighlightEntry[] Snapshot (IEnumerable entries) { ArgumentNullException.ThrowIfNull(entries); - return [.. entries.Where(e => e.IsHideLine && !e.IsSearchHit).Select(e => (HighlightEntry)e.Clone())]; + return [.. entries.Where(HighlightEvaluator.IsHideRule).Select(e => (HighlightEntry)e.Clone())]; } private static bool SameRules (HighlightEntry[] current, HighlightEntry[] next) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index af6c275f..ee588c9a 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -416,7 +416,7 @@ public static string Columnizer_SnapshotUnavailable { return ResourceManager.GetString("Columnizer_SnapshotUnavailable", resourceCulture); } } - + /// /// Looks up a localized string similar to Timestamp selector. /// @@ -4199,7 +4199,7 @@ public static string MarkerBar_Bookmarks { return ResourceManager.GetString("MarkerBar_Bookmarks", resourceCulture); } } - + /// /// Looks up a localized string similar to Clear search. /// @@ -4208,7 +4208,7 @@ public static string MarkerBar_ClearSearch { return ResourceManager.GetString("MarkerBar_ClearSearch", resourceCulture); } } - + /// /// Looks up a localized string similar to Discovering markers…. /// @@ -4217,7 +4217,7 @@ public static string MarkerBar_Discovering { return ResourceManager.GetString("MarkerBar_Discovering", resourceCulture); } } - + /// /// Looks up a localized string similar to Filter hits. /// @@ -4226,7 +4226,7 @@ public static string MarkerBar_FilterHits { return ResourceManager.GetString("MarkerBar_FilterHits", resourceCulture); } } - + /// /// Looks up a localized string similar to Highlights. /// @@ -4235,7 +4235,7 @@ public static string MarkerBar_Highlights { return ResourceManager.GetString("MarkerBar_Highlights", resourceCulture); } } - + /// /// Looks up a localized string similar to Could not read log line {0} while discovering markers.. /// @@ -4244,7 +4244,7 @@ public static string MarkerBar_LineUnavailable { return ResourceManager.GetString("MarkerBar_LineUnavailable", resourceCulture); } } - + /// /// Looks up a localized string similar to Marker discovery failed: {0}. /// @@ -4253,7 +4253,7 @@ public static string MarkerBar_ScanFailed { return ResourceManager.GetString("MarkerBar_ScanFailed", resourceCulture); } } - + /// /// Looks up a localized string similar to Search hits. /// @@ -4262,7 +4262,7 @@ public static string MarkerBar_SearchHits { return ResourceManager.GetString("MarkerBar_SearchHits", resourceCulture); } } - + /// /// Looks up a localized string similar to Marker bar. /// @@ -4271,7 +4271,7 @@ public static string MarkerBar_Title { return ResourceManager.GetString("MarkerBar_Title", resourceCulture); } } - + /// /// Looks up a localized string similar to {0}: lines {1}–{2}, {3} matching lines. /// @@ -4280,7 +4280,7 @@ public static string MarkerBar_ToolTip { return ResourceManager.GetString("MarkerBar_ToolTip", resourceCulture); } } - + /// /// Looks up a localized string similar to Close existing tabs. /// @@ -4447,16 +4447,12 @@ public static string MultiFileMaskDialog_UI_Label_SettingsFor { /// Looks up a localized string similar to Pattern syntax: /// ///* = any characters (wildcard) - ///$D(<date>) = Date pattern + ///$D(&lt;date&gt;) = Date pattern ///$I = File index number ///$J = File index number, hidden when zero - ///$J(<prefix>) = Like $J, but adding <prefix> when non-zero - /// - ///Examples: - ///*$J(.) → app.log, app.log.1, app.log.2 - ///*$J(.).log → app.log, app.1.log, app.2.log + ///$J(&lt;prefix&gt;) = Like $J, but adding &lt;prefix&gt; when non-zero /// - ///<date>: + ///&lt;date&gt;: ///DD = day ///MM = month ///YY[YY] = year @@ -5628,7 +5624,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Show Error Message?. /// @@ -5646,7 +5642,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Show highlight markers. /// @@ -5655,7 +5651,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Show marker bar. /// @@ -5664,7 +5660,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar", resourceCulture); } } - + /// /// Looks up a localized string similar to Show search markers. /// @@ -5673,7 +5669,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers { return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers", resourceCulture); } } - + /// /// Looks up a localized string similar to Allow only 1 Instance. /// @@ -6592,7 +6588,7 @@ public static string SettingsDialog_UI_TabPage_tabPageMarkerBar { return ResourceManager.GetString("SettingsDialog_UI_TabPage_tabPageMarkerBar", resourceCulture); } } - + /// /// Looks up a localized string similar to Memory/CPU. /// diff --git a/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs index a4fa4cb7..a6ffa31e 100644 --- a/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs +++ b/src/LogExpert.Tests/ConfigManagerTests/ConfigManagerTest.cs @@ -1,6 +1,7 @@ using System.Reflection; using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Classes.Highlight; using LogExpert.Core.Config; using LogExpert.Core.Entities; @@ -1061,5 +1062,49 @@ public void LoadOrCreateNew_BothPropertiesInJSON_NoDuplicates () Assert.That(loadResult.Settings.Preferences.HighlightGroupList[0].GroupName, Is.EqualTo("Group1")); } + [Test] + [Category("BackwardCompatibility")] + public void HighlightExportImport_KeepsHideLineFlag () + { + FileInfo exportFile = new(Path.Join(_testDir, "highlights.json")); + _configManager.Settings.Preferences.HighlightGroupList = + [ + new HighlightGroup + { + GroupName = "HideGroup", + HighlightEntryList = [new HighlightEntry { SearchText = "DEBUG", IsHideLine = true }, new HighlightEntry { SearchText = "INFO" }] + } + ]; + + _configManager.Export(exportFile, SettingsFlags.HighlightSettings); + _configManager.Settings.Preferences.HighlightGroupList.Clear(); + _configManager.ImportHighlightSettings(exportFile, ExportImportFlags.HighlightSettings); + + var entries = _configManager.Settings.Preferences.HighlightGroupList.Single(group => group.GroupName == "HideGroup").HighlightEntryList; + Assert.That(entries.Select(entry => entry.IsHideLine), Is.EqualTo(new[] { true, false })); + } + + [Test] + [Category("BackwardCompatibility")] + public void HighlightImport_LegacyFileWithoutHideLine_LoadsAsNotHidden () + { + string legacyJson = @"[ + { + ""GroupName"": ""LegacyGroup"", + ""HighlightEntryList"": [ + { ""SearchText"": ""DEBUG"", ""IsRegex"": false, ""IsSetBookmark"": true } + ] + } +]"; + FileInfo importFile = new(Path.Join(_testDir, "legacy_highlights.json")); + File.WriteAllText(importFile.FullName, legacyJson); + + _configManager.ImportHighlightSettings(importFile, ExportImportFlags.HighlightSettings); + + var entry = _configManager.Settings.Preferences.HighlightGroupList.Single(group => group.GroupName == "LegacyGroup").HighlightEntryList.Single(); + Assert.That(entry.IsSetBookmark, Is.True); + Assert.That(entry.IsHideLine, Is.False); + } + #endregion } diff --git a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs index 726cab18..baa672f1 100644 --- a/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs +++ b/src/LogExpert.Tests/Highlight/LineVisibilityTrackerTests.cs @@ -126,11 +126,11 @@ public void Extend_EvaluatesAppendedLines_AsAnAppendOfTheCurrentMap () var before = _tracker.Map; _lines.AddRange(["DEBUG f", "INFO g"]); - var after = _tracker.Extend(7); + _tracker.Extend(7); + var after = _tracker.Map; Assert.That(VisibleLines(after), Is.EqualTo(new[] { 0, 2, 4, 6 })); Assert.That(after.IsAppendOf(before), Is.True); - Assert.That(_tracker.Map, Is.SameAs(after)); } [Test] @@ -140,7 +140,9 @@ public void Extend_ToAKnownLineCount_ChangesNothing () Idle(); var before = _tracker.Map; - Assert.That(_tracker.Extend(3), Is.SameAs(before)); + _tracker.Extend(3); + + Assert.That(_tracker.Map, Is.SameAs(before)); } [Test] @@ -219,7 +221,8 @@ public void Shift_RenumbersTheCurrentMap () _tracker.Load(_lines.Count, HideDebug); Idle(); - var shifted = _tracker.Shift(2); + _tracker.Shift(2); + var shifted = _tracker.Map; Assert.That(shifted.LineCount, Is.EqualTo(3)); Assert.That(VisibleLines(shifted), Is.EqualTo(new[] { 0, 2 })); @@ -235,7 +238,7 @@ public void Shift_WhileScanPending_RestartsTheScanOnTheNewNumbering () _lines.RemoveRange(0, 2); } - _ = _tracker.Shift(2); + _tracker.Shift(2); _gate.Set(); Idle(); @@ -254,9 +257,43 @@ public void Replace_ReevaluatesEveryLineOfTheNewContent () _lines.AddRange(["DEBUG x", "INFO y"]); } - var replaced = _tracker.Replace(2); + _tracker.Replace(2); + + Assert.That(VisibleLines(_tracker.Map), Is.EqualTo(new[] { 1 })); + } - Assert.That(VisibleLines(replaced), Is.EqualTo(new[] { 1 })); + [Test] + public void EveryLine_IsReadWhileItsBufferIsPinned () + { + var pinned = (First: -1, Last: -1); + var unpinnedReads = new List(); + using var tracker = new LineVisibilityTracker( + i => + { + if (i < pinned.First || i > pinned.Last) + { + unpinnedReads.Add(i); + } + + return new LogLine(i % 2 == 0 ? "DEBUG" : "INFO", i); + }, + (first, last) => + { + pinned = (first, last); + return new ActionDisposable(() => pinned = (-1, -1)); + }); + + tracker.Load(1000, HideDebug); + Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True); + tracker.Extend(1100); + + Assert.That(unpinnedReads, Is.Empty); + Assert.That(tracker.Map.HiddenCount, Is.EqualTo(550)); + } + + private sealed class ActionDisposable (Action dispose) : IDisposable + { + public void Dispose () => dispose(); } [Test] @@ -293,7 +330,8 @@ public void TailFailure_FallsBackToAllVisible () Assert.That(tracker.WhenIdle().Wait(Timeout), Is.True); fail = true; - var map = tracker.Extend(6); + tracker.Extend(6); + var map = tracker.Map; Assert.That(map.LineCount, Is.EqualTo(6)); Assert.That(map.HiddenCount, Is.Zero); @@ -314,7 +352,7 @@ public void ReaderCallInProgress_NeverBlocksTheOtherCallers () { _ = _tracker.IsScanning; _tracker.Rebuild([new HighlightEntry { SearchText = "INFO", IsHideLine = true }]); - _ = _tracker.Shift(0); + _tracker.Shift(0); _tracker.Load(_lines.Count, HideDebug); }); diff --git a/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs index 2e297f43..509289ac 100644 --- a/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs +++ b/src/LogExpert.Tests/Marker/MarkerCriteriaTests.cs @@ -63,4 +63,26 @@ public void Highlights_FirstVisualRuleWinsAndCriteriaAreSnapshotted () Assert.That(match, Is.EqualTo(new MarkerLine(7, Color.Red.ToArgb(), 1))); } + + [Test] + public void Highlights_HideRuleWithColors_StillProducesAMarker () + { + var criteria = MarkerCriteria.ForHighlights([ + new HighlightEntry { SearchText = "DEBUG", IsHideLine = true, BackgroundColor = Color.Orange } + ]); + + Assert.That(criteria.IsEmpty, Is.False); + Assert.That(criteria.Match(4, new LogLine("DEBUG 4", 4)), Is.EqualTo(new MarkerLine(4, Color.Orange.ToArgb(), 0))); + } + + [Test] + public void Highlights_HideRuleWithoutVisualAttributes_ProducesNoMarker () + { + var criteria = MarkerCriteria.ForHighlights([ + new HighlightEntry { SearchText = "DEBUG", IsHideLine = true } + ]); + + Assert.That(criteria.IsEmpty, Is.True); + Assert.That(criteria.Match(4, new LogLine("DEBUG 4", 4)), Is.Null); + } } \ No newline at end of file diff --git a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs index 87985173..478d1b5e 100644 --- a/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs +++ b/src/LogExpert.UI.Tests/HiddenLinesWindowTests.cs @@ -131,6 +131,22 @@ public void Load_ShowsNoRowsUntilTheFirstScanHasFinished () Assert.That(largest, Is.EqualTo(10_000), "hidden lines must never be shown while the first scan runs"); } + [Test] + public void GotoLine_BeforeTheFirstScanHasFinished_IsAppliedOnceItHas () + { + File.WriteAllLines(_fileName, Enumerable.Range(0, 20_000).Select(Text)); + _window = new LogTabWindow([_fileName], 1, false, _config.Object) { ShowInTaskbar = false, Opacity = 0 }; + Find(_window, "dockPanel").ShowDocumentIcon = false; + _window.Show(); + PumpUntil(() => _window.CurrentLogWindow != null); + var log = _window.CurrentLogWindow; + + log.GotoLine(12_345); + PumpUntil(() => log.CurrentLineNum == 12_345); + + Assert.That(log.ShowHiddenLines, Is.True); + } + [TestCase(12, 12)] [TestCase(13, 14)] public void SavedPosition_IsRestoredOnceTheFirstScanHasFinished (int savedLine, int expectedLine) @@ -460,6 +476,102 @@ public void WindowFilter_StillFindsHiddenLines () Assert.That(Grid(log).RowCount, Is.EqualTo(LINE_COUNT / 2)); } + [Test] + public void FilterTab_HidesLinesThroughItsOwnGroup_AndLocateRevealsTheHiddenOriginalLine () + { + _settings.Preferences.HighlightGroupList.Add(new HighlightGroup + { + GroupName = "hide-some", + HighlightEntryList = [new HighlightEntry { SearchText = "DEBUG (3|7|11|15|19)$", IsRegex = true, IsHideLine = true }] + }); + var origin = Open(); + if (!Find