From 08b9b86dfb743be3ad1a82d87aa2054e32ae0afa Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 14:37:39 +0200 Subject: [PATCH 01/18] feat: Filter Engine module in Core, table-tested and unused (ticket 1) IFilterEngine + FilterRun (Completed/Cancelled/Failed, partial lists stand) with two engines held to one contract: SerialFilterEngine (the Log Window loop, ported behind the seam) and ParallelFilterEngine (wrapping FilterStarter chunk-and-merge). FilterAccumulator is the single home of the per-hit spread recipe, shared later by the tail path. Contract pinned by a dual-engine equivalence table: sorted/deduped output byte-identical across engines, params + LineCount snapshotted at entry, chunk-boundary spread overlap, range filters spanning chunks, cancellation and throwing conditions as narrated outcomes (fixes the parallel path crash once call sites migrate). FilterStarter no longer raises the process-wide ThreadPool minimum (deliberate, spec-noted). Nothing in the UI changes yet. Co-Authored-By: Claude Fable 5 --- .../Classes/Filter/FilterAccumulator.cs | 34 ++ .../Classes/Filter/FilterRun.cs | 27 ++ .../Classes/Filter/FilterStarter.cs | 4 - .../Classes/Filter/IFilterEngine.cs | 26 ++ .../Classes/Filter/ParallelFilterEngine.cs | 83 +++++ .../Classes/Filter/SerialFilterEngine.cs | 91 ++++++ .../Filter/FilterAccumulatorTests.cs | 34 ++ .../Filter/FilterEngineEquivalenceTests.cs | 305 ++++++++++++++++++ 8 files changed, 600 insertions(+), 4 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs create mode 100644 src/LogExpert.Core/Classes/Filter/FilterRun.cs create mode 100644 src/LogExpert.Core/Classes/Filter/IFilterEngine.cs create mode 100644 src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs create mode 100644 src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs create mode 100644 src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs create mode 100644 src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs diff --git a/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs b/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs new file mode 100644 index 00000000..02b82a39 --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs @@ -0,0 +1,34 @@ +namespace LogExpert.Core.Classes.Filter; + +/// +/// The single home of the per-hit filter accumulation recipe: hit → → +/// append to results/history → . Used internally by the serial +/// Filter Engine and per line by the Log Window's tail filter path, so the recipe exists exactly once. +/// Not thread-safe — callers that share an instance with concurrent readers (the tail path's GUI) +/// synchronize around it. +/// +public class FilterAccumulator +{ + /// Filter result lines: every hit plus its spread context, in accumulation order. + public List ResultLines { get; } = []; + + /// The hit lines only, without spread. + public List HitLines { get; } = []; + + /// The trailing dedup window (spread history) recent expansions were checked against. + public List History { get; } = []; + + /// + /// Records a filter hit: adds it to and appends its spread expansion + /// (deduplicated against , clamped to the file's line range) to + /// . + /// + public void AddHit (int lineNum, int spreadBefore, int spreadBehind, int lineCount) + { + HitLines.Add(lineNum); + var expanded = FilterSpread.Expand(lineNum, spreadBefore, spreadBehind, lineCount, History); + ResultLines.AddRange(expanded); + History.AddRange(expanded); + FilterSpread.TrimHistory(History); + } +} diff --git a/src/LogExpert.Core/Classes/Filter/FilterRun.cs b/src/LogExpert.Core/Classes/Filter/FilterRun.cs new file mode 100644 index 00000000..0f517334 --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/FilterRun.cs @@ -0,0 +1,27 @@ +namespace LogExpert.Core.Classes.Filter; + +/// How a Filter Run ended. +public enum FilterRunOutcome +{ + /// All lines in the run's range were filtered. + Completed, + + /// The run observed cancellation; the lists hold the partial results accumulated so far. + Cancelled, + + /// The filter condition threw; carries the exception. + /// The caller narrates — the engine never rethrows. + Failed, +} + +/// +/// The result of one Filter Run. and are sorted +/// ascending without duplicates (the engine contract); is the spread-dedup +/// window state in accumulation order, handed to the tail filter path to continue from. +/// +public sealed record FilterRun ( + IReadOnlyList ResultLines, + IReadOnlyList HitLines, + IReadOnlyList History, + FilterRunOutcome Outcome, + Exception Error = null); diff --git a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs index be2f03cc..aa4d663b 100644 --- a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs +++ b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs @@ -42,11 +42,7 @@ public FilterStarter (ColumnizerCallback callback, int minThreads) _filterHitDict = []; _filterResultDict = []; _lastFilterLinesDict = []; - ThreadCount = Environment.ProcessorCount * 4; ThreadCount = minThreads; - ThreadPool.GetMinThreads(out _, out var completion); - _ = ThreadPool.SetMinThreads(minThreads, completion); - ThreadPool.GetMaxThreads(out _, out _); } #endregion diff --git a/src/LogExpert.Core/Classes/Filter/IFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/IFilterEngine.cs new file mode 100644 index 00000000..d3ee56ae --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/IFilterEngine.cs @@ -0,0 +1,26 @@ +using LogExpert.Core.Callback; + +namespace LogExpert.Core.Classes.Filter; + +/// +/// A Filter Engine executes one full Filter Run over a log file and returns a . +/// Two engines implement the seam — and , +/// selected by the MultiThreadFilter preference — and are held to an identical contract: +/// +/// and are sorted ascending with no duplicates. +/// is snapshotted at entry — mutating the caller's instance mid-run has no effect. +/// The run covers [0, LineCount-at-entry); lines appended during the run belong to the tail path. +/// The engine never throws for filter errors and never touches UI: failures, like cancellation, +/// are reported as the run's . +/// +/// The dual-engine equivalence test table pins serial and parallel to byte-identical output. +/// +public interface IFilterEngine +{ + /// + /// Runs the filter over all lines available at entry. Progress is a cumulative scanned-line + /// count through the sink; cancellation yields with the + /// partial lists accumulated so far. + /// + FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress progress = null); +} diff --git a/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs new file mode 100644 index 00000000..cca84467 --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs @@ -0,0 +1,83 @@ +using LogExpert.Core.Callback; + +using NLog; + +namespace LogExpert.Core.Classes.Filter; + +/// +/// The multi-threaded Filter Engine: wraps 's chunk-and-merge machinery +/// (the file is split into ProcessorCount + 2 intervals, each filtered on its own worker with a +/// cloned , results merged sorted and deduplicated). Worker faults surface +/// as — never as a rethrow — and cancellation is forwarded to +/// the workers through the token. See for the shared contract. +/// +public class ParallelFilterEngine : IFilterEngine +{ + /// Chunk count beyond the processor count — today's effective FilterStarter value. + private const int CHUNK_FACTOR = 2; + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + private readonly int _chunkCount; + + public ParallelFilterEngine () : this(Environment.ProcessorCount + CHUNK_FACTOR) + { + } + + /// Fixed chunk count — used by tests to make chunk boundaries deterministic. + public ParallelFilterEngine (int chunkCount) + { + _chunkCount = chunkCount; + } + + public FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress progress = null) + { + ArgumentNullException.ThrowIfNull(filterParams); + ArgumentNullException.ThrowIfNull(callback); + + // Snapshot: the caller may mutate its instance while the run executes (workers clone + // again per thread, but their source must already be private to this run). + var snapshot = filterParams.CloneWithCurrentColumnizer(); + snapshot.Reset(); + + // Snapshot: lines appended during the run belong to the tail path. + var lineCount = callback.GetLineCount(); + + FilterStarter filterStarter = new(callback, _chunkCount); + + using var cancelRegistration = cancellationToken.Register(filterStarter.CancelFilter); + + try + { + filterStarter + .DoFilter(snapshot, 0, lineCount, count => progress?.Report(count)) + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + _logger.Error(ex, "Exception while filtering (parallel engine)"); + return Snapshot(filterStarter, FilterRunOutcome.Failed, ex); + } + + var outcome = cancellationToken.IsCancellationRequested + ? FilterRunOutcome.Cancelled + : FilterRunOutcome.Completed; + + return Snapshot(filterStarter, outcome); + } + + /// + /// Materializes the run result. FilterStarter's merge already yields sorted-ascending, + /// deduplicated lists, satisfying the engine contract. + /// + private static FilterRun Snapshot (FilterStarter filterStarter, FilterRunOutcome outcome, Exception error = null) + { + return new FilterRun( + [.. filterStarter.FilterResultLines], + [.. filterStarter.FilterHitList], + [.. filterStarter.LastFilterLinesList], + outcome, + error); + } +} diff --git a/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs new file mode 100644 index 00000000..9b26843c --- /dev/null +++ b/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs @@ -0,0 +1,91 @@ +using LogExpert.Core.Callback; + +using NLog; + +namespace LogExpert.Core.Classes.Filter; + +/// +/// The single-threaded Filter Engine: one sequential pass over the file, accumulating through +/// . Ported from the Log Window's private filter loop; behaviour +/// changes versus that loop are the engine contract (params + line-count snapshot at entry, +/// Failed outcome instead of a MessageBox) — see . +/// +public class SerialFilterEngine : IFilterEngine +{ + private const int PROGRESS_REPORT_MODULO = 1000; + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + public FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, CancellationToken cancellationToken, IProgress progress = null) + { + ArgumentNullException.ThrowIfNull(filterParams); + ArgumentNullException.ThrowIfNull(callback); + + // Snapshot: the caller may mutate its instance (filter panel edits) while the run executes. + var snapshot = filterParams.CloneWithCurrentColumnizer(); + snapshot.Reset(); + + // Snapshot: lines appended during the run belong to the tail path. + var lineCount = callback.GetLineCount(); + + var accumulator = new FilterAccumulator(); + var outcome = FilterRunOutcome.Completed; + + try + { + for (var lineNum = 0; lineNum < lineCount; lineNum++) + { + var line = callback.GetLogLineMemory(lineNum); + if (line == null) + { + break; + } + + callback.SetLineNum(lineNum); + if (Util.TestFilterCondition(snapshot, line, callback)) + { + accumulator.AddHit(lineNum, snapshot.SpreadBefore, snapshot.SpreadBehind, lineCount); + } + + // Checked after the line is processed, matching the original loop: a cancel + // requested mid-line still keeps that line's hit in the partial result. + if (cancellationToken.IsCancellationRequested) + { + outcome = FilterRunOutcome.Cancelled; + break; + } + + if ((lineNum + 1) % PROGRESS_REPORT_MODULO == 0) + { + progress?.Report(lineNum + 1); + } + } + } + catch (Exception ex) + { + _logger.Error(ex, "Exception while filtering (serial engine)"); + return Snapshot(accumulator, FilterRunOutcome.Failed, ex); + } + + return Snapshot(accumulator, outcome); + } + + /// + /// Materializes the run result under the engine contract: results and hits sorted ascending, + /// duplicates removed; history kept in accumulation order (it is dedup-window state, not output). + /// + private static FilterRun Snapshot (FilterAccumulator accumulator, FilterRunOutcome outcome, Exception error = null) + { + return new FilterRun( + Normalize(accumulator.ResultLines), + Normalize(accumulator.HitLines), + [.. accumulator.History], + outcome, + error); + } + + private static List Normalize (List lines) + { + var normalized = new List(new SortedSet(lines)); + return normalized; + } +} diff --git a/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs b/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs new file mode 100644 index 00000000..7ad51373 --- /dev/null +++ b/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs @@ -0,0 +1,34 @@ +using LogExpert.Core.Classes.Filter; + +using NUnit.Framework; + +namespace LogExpert.Tests.Filter; + +[TestFixture] +public class FilterAccumulatorTests +{ + /// + /// The accumulator is the single home of the per-hit recipe (hit → Expand → append → + /// TrimHistory). Expected values are the worked example pinned by + /// ' accumulation-recipe guard: hits 5, 7 and 50 with + /// spread 2/2 in a 100-line file. + /// + [Test] + public void AddHit_OverlappingHitSequence_ProducesPinnedResultHitAndHistoryLists () + { + var accumulator = new FilterAccumulator(); + + accumulator.AddHit(5, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + accumulator.AddHit(7, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + accumulator.AddHit(50, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + + Assert.Multiple(() => + { + // Hit 5 contributes 3..7; hit 7 contributes only 8,9 (5..7 already taken); hit 50 contributes 48..52. + Assert.That(accumulator.ResultLines, Is.EqualTo(new[] { 3, 4, 5, 6, 7, 8, 9, 48, 49, 50, 51, 52 })); + Assert.That(accumulator.HitLines, Is.EqualTo(new[] { 5, 7, 50 })); + Assert.That(accumulator.History, Is.EqualTo(accumulator.ResultLines), + "history below the window size mirrors the result lines"); + }); + } +} diff --git a/src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs b/src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs new file mode 100644 index 00000000..fe919371 --- /dev/null +++ b/src/LogExpert.Tests/Filter/FilterEngineEquivalenceTests.cs @@ -0,0 +1,305 @@ +using ColumnizerLib; + +using LogExpert.Core.Callback; +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Interfaces; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Filter; + +/// +/// The Filter Engine contract: every fixture runs through every engine and must produce +/// byte-identical, sorted-ascending, duplicate-free ResultLines/HitLines and the same Outcome. +/// Serial and parallel are held to each other by the same table. +/// +[TestFixture] +public class FilterEngineEquivalenceTests +{ + private static IEnumerable Engines () + { + yield return new SerialFilterEngine(); + // Fixed chunk count so the chunk-boundary fixtures land deterministically on every machine. + yield return new ParallelFilterEngine(chunkCount: 4); + } + + [TestCaseSource(nameof(Engines))] + public void Run_PlainTextHitsWithSpread_ReturnsCompletedSortedResults (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ERROR1", "ok2", "ok3", "ok4", "ERROR5", "ok6"); + var filterParams = ParamsOf("ERROR", spreadBefore: 1, spreadBehind: 1); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + // Hit 1 contributes 0..2, hit 5 contributes 4..6. + Assert.That(run.ResultLines, Is.EqualTo(new[] { 0, 1, 2, 4, 5, 6 })); + Assert.That(run.HitLines, Is.EqualTo(new[] { 1, 5 })); + }); + } + + /// + /// 20 lines, chunk count 4 → parallel chunk boundaries at 5, 10, 15. Hits at 4 and 5 sit on + /// either side of a boundary with spread 2 — each parallel worker starts with an empty dedup + /// window, so the overlap (lines 3..7) is emitted by both workers and must be merged away to + /// match the serial single-pass output. + /// + [TestCaseSource(nameof(Engines))] + public void Run_SpreadOverlapStraddlingChunkBoundary_MatchesSerialOutput (IFilterEngine engine) + { + var lines = Enumerable.Range(0, 20).Select(i => i is 4 or 5 ? $"ERROR{i}" : $"ok{i}").ToArray(); + var callback = CallbackOf(lines); + var filterParams = ParamsOf("ERROR", spreadBefore: 2, spreadBehind: 2); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + // Hit 4 contributes 2..6, hit 5 contributes only 7 — one deduplicated span. + Assert.That(run.ResultLines, Is.EqualTo(new[] { 2, 3, 4, 5, 6, 7 })); + Assert.That(run.HitLines, Is.EqualTo(new[] { 4, 5 })); + }); + } + + /// + /// Range filter (begin/end markers) spanning parallel chunk boundaries: begin at 2, end at 12, + /// chunks of 5. The worker owning the begin marker must overrun its chunk while in range; the + /// mid-range workers see no marker and contribute nothing; the merge yields the serial range. + /// + [TestCaseSource(nameof(Engines))] + public void Run_RangeFilterSpanningChunkBoundary_MatchesSerialOutput (IFilterEngine engine) + { + var lines = Enumerable.Range(0, 20) + .Select(i => i == 2 ? "BEGIN here" : i == 12 ? "END here" : $"ok{i}") + .ToArray(); + var callback = CallbackOf(lines); + var filterParams = ParamsOf("BEGIN"); + filterParams.IsRangeSearch = true; + filterParams.RangeSearchText = "END"; + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + // Every line from the begin marker through the end marker, inclusive. + Assert.That(run.HitLines, Is.EqualTo(Enumerable.Range(2, 11))); + Assert.That(run.ResultLines, Is.EqualTo(Enumerable.Range(2, 11))); + }); + } + + /// + /// A throwing filter condition (column-restrict with no columnizer — the realistic + /// "columnizer changed mid-flight" fault) is a narrated outcome, never a rethrow. This is the + /// fixture that pins the parallel path's crash fix: today's MultiThreadedFilter would take the + /// app down on this input. + /// + [TestCaseSource(nameof(Engines))] + public void Run_ThrowingFilterCondition_ReturnsFailedWithError (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ok1"); + var filterParams = ParamsOf("ERROR"); + filterParams.ColumnRestrict = true; + filterParams.ColumnList.Add(0); + filterParams.CurrentColumnizer = null; + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Failed)); + Assert.That(run.Error, Is.Not.Null); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_CancelledToken_ReturnsCancelledWithoutThrowing (IFilterEngine engine) + { + var callback = CallbackOf("ERROR0", "ERROR1", "ERROR2"); + var filterParams = ParamsOf("ERROR"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var run = engine.Run(filterParams, callback, cts.Token); + + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Cancelled)); + } + + /// + /// Serial-only pin: the cancel check sits after the line is processed (matching the original + /// loop), so a cancel requested before the first line still keeps that line's hit in the + /// partial result. + /// + [Test] + public void Run_Serial_CancelObservedAfterLineIsProcessed_KeepsFirstLinesHit () + { + var callback = CallbackOf("ERROR0", "ERROR1", "ERROR2"); + var filterParams = ParamsOf("ERROR"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var run = new SerialFilterEngine().Run(filterParams, callback, cts.Token); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Cancelled)); + Assert.That(run.HitLines, Is.EqualTo(new[] { 0 }), "partial results stand on cancel"); + }); + } + + /// + /// The engine snapshots FilterParams at entry: a caller mutating the shared instance while the + /// run executes (filter-panel edits) cannot affect the run. The mutation is injected through the + /// reader so it happens mid-run, synchronously. + /// + [TestCaseSource(nameof(Engines))] + public void Run_ParamsMutatedMidRun_OutcomeUnaffected (IFilterEngine engine) + { + var filterParams = ParamsOf("ERROR"); + var callback = CallbackOf(["ok0", "ERROR1", "ok2", "ERROR3"], + onLineRead: _ => filterParams.SearchText = "NEVERMATCHES"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.That(run.HitLines, Is.EqualTo(new[] { 1, 3 }), + "the snapshot taken at entry governs the whole run"); + } + + /// + /// The engine covers [0, LineCount-at-entry): lines appended while the run executes are not + /// filtered — they belong to the tail path. Pins the deliberate serial change (the old loop + /// chased EOF). + /// + [TestCaseSource(nameof(Engines))] + public void Run_LinesAppendedMidRun_AreNotFiltered (IFilterEngine engine) + { + var lines = new List { "ERROR0", "ok1", "ok2" }; + var callback = CallbackOf(lines, + onLineRead: _ => + { + if (lines.Count == 3) + { + lines.Add("ERROR3"); + lines.Add("ERROR4"); + } + }); + var filterParams = ParamsOf("ERROR"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.That(run.HitLines, Is.EqualTo(new[] { 0 }), + "lines appended after Run entry belong to the tail path"); + } + + [TestCaseSource(nameof(Engines))] + public void Run_HitAtLineZeroWithBackSpread_ClampsAtStartAndKeepsLineZero (IFilterEngine engine) + { + var callback = CallbackOf("ERROR0", "ok1", "ok2"); + var filterParams = ParamsOf("ERROR", spreadBefore: 2, spreadBehind: 1); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.ResultLines, Is.EqualTo(new[] { 0, 1 }), "line 0 is a valid hit; back spread clamps at the file start"); + Assert.That(run.HitLines, Is.EqualTo(new[] { 0 })); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_HitAtLastLineWithForeSpread_ClampsAtEndOfFile (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ok1", "ERROR2"); + var filterParams = ParamsOf("ERROR", spreadBefore: 1, spreadBehind: 2); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.ResultLines, Is.EqualTo(new[] { 1, 2 }), "fore spread clamps at the last line"); + Assert.That(run.HitLines, Is.EqualTo(new[] { 2 })); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_NoHits_ReturnsCompletedEmptyLists (IFilterEngine engine) + { + var callback = CallbackOf("ok0", "ok1", "ok2"); + var filterParams = ParamsOf("ERROR"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + Assert.That(run.ResultLines, Is.Empty); + Assert.That(run.HitLines, Is.Empty); + }); + } + + [TestCaseSource(nameof(Engines))] + public void Run_EmptyFile_ReturnsCompletedEmptyLists (IFilterEngine engine) + { + var callback = CallbackOf(); + var filterParams = ParamsOf("ERROR"); + + var run = engine.Run(filterParams, callback, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(run.Outcome, Is.EqualTo(FilterRunOutcome.Completed)); + Assert.That(run.ResultLines, Is.Empty); + Assert.That(run.HitLines, Is.Empty); + }); + } + + #region Fixture helpers + + private static ColumnizerCallback CallbackOf (params string[] lines) + { + return CallbackOf(lines.ToList(), onLineRead: null); + } + + private static ColumnizerCallback CallbackOf (IList lines, Action onLineRead) + { + var reader = new Mock(); + _ = reader.Setup(r => r.LineCount).Returns(() => lines.Count); // live — grows when a fixture appends + + var window = new Mock(); + _ = window.Setup(w => w.LogFileReader).Returns(reader.Object); + _ = window.Setup(w => w.GetLineMemory(It.IsAny())) + .Returns((int lineNum) => + { + onLineRead?.Invoke(lineNum); + return lineNum >= 0 && lineNum < lines.Count ? LineOf(lines[lineNum]) : null!; + }); + + return new ColumnizerCallback(window.Object); + } + + private static ILogLineMemory LineOf (string text) + { + var mock = new Mock(); + _ = mock.Setup(l => l.FullLine).Returns(text.AsMemory()); + return mock.Object; + } + + private static FilterParams ParamsOf (string searchText, int spreadBefore = 0, int spreadBehind = 0) + { + var filterParams = new FilterParams + { + SearchText = searchText, + SpreadBefore = spreadBefore, + SpreadBehind = spreadBehind, + }; + filterParams.Init(); + return filterParams; + } + + #endregion +} From 5339e04ff6687929bef83081a6643cf8828c03c0 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 14:40:17 +0200 Subject: [PATCH 02/18] refactor: unify the tail trigger-dispatch loop bodies (ticket 2) CheckFilterAndHighlight had two near-identical ~40-line loop bodies (filter- tail branch vs no-filter branch) duplicating the whole trigger dispatch: match -> plugins -> GetTriggerActions -> audio -> bookmark -> stop-tail -> LED. One loop now carries the dispatch once, with the filter work gated by a doFilter condition; rollover handling hoisted above. Deliberate micro-change (spec-noted): a null line breaks and still flushes the filter GUI update and dirty LED for work already done - the old filter branch returned early and swallowed that flush. All trigger side effects stay at the call site, keeping the Tail trigger path invariant enforced by construction. Co-Authored-By: Claude Fable 5 --- .../Controls/LogWindow/LogWindow.cs | 138 ++++++------------ 1 file changed, 45 insertions(+), 93 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 9a831067..f888c457 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -3170,127 +3170,79 @@ private void UpdateGrid (LogEventArgs logEventArgs) private void CheckFilterAndHighlight (LogEventArgs e) { + // The Tail trigger path (CONTEXT.md): the one loop that evaluates highlight entries against + // newly appended lines. Side-effecting triggers (Audio Alert, Set Bookmark, Stop Tail) fire + // here and only here — the bulk scanner (HighlightBookmarkScanner) has no access to them. + var doFilter = filterTailCheckBox.Checked || _filterPipeList.Count > 0; var noLed = true; + var firstStopTail = true; + var filterLineAdded = false; - if (filterTailCheckBox.Checked || _filterPipeList.Count > 0) + var startLine = e.PrevLineCount; + if (e.IsRollover) { - var filterStart = e.PrevLineCount; - if (e.IsRollover) + ShiftFilterLines(e.RolloverOffset); + startLine -= e.RolloverOffset; + } + + ColumnizerCallback callback = new(this); + + for (var i = startLine; i < e.LineCount; ++i) + { + var line = _logFileReader.GetLogLineMemory(i); + if (line == null) { - ShiftFilterLines(e.RolloverOffset); - filterStart -= e.RolloverOffset; + // End of available lines — stop scanning, but still flush the work already done below. + break; } - var firstStopTail = true; - ColumnizerCallback callback = new(this); - var filterLineAdded = false; - for (var i = filterStart; i < e.LineCount; ++i) + if (doFilter) { - var line = _logFileReader.GetLogLineMemory(i); - if (line == null) - { - return; - } - if (filterTailCheckBox.Checked) { callback.SetLineNum(i); if (Util.TestFilterCondition(_filterParams, line, callback)) { - //AddFilterLineFx addFx = new AddFilterLineFx(AddFilterLine); - //this.Invoke(addFx, new object[] { i, true }); filterLineAdded = true; AddFilterLine(i, false, _filterParams, _filterResultList, _lastFilterLinesList, _filterHitList); } } - //ProcessFilterPipeFx pipeFx = new ProcessFilterPipeFx(ProcessFilterPipes); - //pipeFx.BeginInvoke(i, null, null); ProcessFilterPipes(i); + } - var matchingList = FindMatchingHighlightEntries(line); - LaunchHighlightPlugins(matchingList, i); - var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); - SafeTriggerAudioAlert(matchingList); - if (setBookmark) - { - var capturedLineNum = i; - var capturedComment = bookmarkComment; - _ = BeginInvoke(() => SetBookmarkFromTrigger(capturedLineNum, capturedComment)); - } - - if (stopTail && _guiStateArgs.FollowTail) - { - var wasFollow = _guiStateArgs.FollowTail; - FollowTailChanged(false, true); - if (firstStopTail && wasFollow) - { - //_ = Invoke(new SelectLineFx(SelectAndEnsureVisible), [i, false]); - var capturedLineNum = i; - _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); - firstStopTail = false; - } - } + var matchingList = FindMatchingHighlightEntries(line); + LaunchHighlightPlugins(matchingList, i); + var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); + SafeTriggerAudioAlert(matchingList); + if (setBookmark) + { + var capturedLineNum = i; + var capturedComment = bookmarkComment; + _ = BeginInvoke(() => SetBookmarkFromTrigger(capturedLineNum, capturedComment)); + } - if (!suppressLed) + if (stopTail && _guiStateArgs.FollowTail) + { + var wasFollow = _guiStateArgs.FollowTail; + FollowTailChanged(false, true); + if (firstStopTail && wasFollow) { - noLed = false; + var capturedLineNum = i; + _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); + firstStopTail = false; } } - if (filterLineAdded) + if (!suppressLed) { - //AddFilterLineGuiUpdateFx addFx = new AddFilterLineGuiUpdateFx(AddFilterLineGuiUpdate); - //this.Invoke(addFx); - TriggerFilterLineGuiUpdate(); + noLed = false; } } - else - { - var firstStopTail = true; - var startLine = e.PrevLineCount; - if (e.IsRollover) - { - ShiftFilterLines(e.RolloverOffset); - startLine -= e.RolloverOffset; - } - - for (var i = startLine; i < e.LineCount; ++i) - { - var line = _logFileReader.GetLogLineMemory(i); - if (line != null) - { - var matchingList = FindMatchingHighlightEntries(line); - LaunchHighlightPlugins(matchingList, i); - var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); - SafeTriggerAudioAlert(matchingList); - if (setBookmark) - { - var capturedLineNum = i; - var capturedComment = bookmarkComment; - _ = BeginInvoke(() => SetBookmarkFromTrigger(capturedLineNum, capturedComment)); - //_ = fx.BeginInvoke(i, bookmarkComment, null, null); - } - if (stopTail && _guiStateArgs.FollowTail) - { - var wasFollow = _guiStateArgs.FollowTail; - FollowTailChanged(false, true); - if (firstStopTail && wasFollow) - { - //_ = Invoke(new SelectLineFx(SelectAndEnsureVisible), [i, false]); - var capturedLineNum = i; - _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); - firstStopTail = false; - } - } - - if (!suppressLed) - { - noLed = false; - } - } - } + if (filterLineAdded) + { + TriggerFilterLineGuiUpdate(); } if (!noLed) From a685b9c70250923e3dd359f57daec924e46ec84b Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 14:46:35 +0200 Subject: [PATCH 03/18] refactor: migrate the full filter run to the Filter Engines (ticket 3) FilterSearch resolves SerialFilterEngine or ParallelFilterEngine from the MultiThreadFilter preference and runs it with a per-run CancellationToken; ESC reaches the run through the existing cancel-handler registry for both engines. The private Filter loop, MultiThreadedFilter, FilterProgressCallback and the FilterFxAction delegate are deleted. The control narrates the FilterRun outcome: durations as before, Failed as a localized status-line error (new resource, en/de/zh-CN) - no MessageBox from the worker, and a throwing condition on the multi-threaded path no longer crashes the app. Also migrated the second, unreviewed serial call site: RestoreFilterTabs now runs each restored Filter Pipe with fresh spread history instead of passing the window-shared _lastFilterLinesList, so a restored tab's content no longer depends on unrelated filter state. Co-Authored-By: Claude Fable 5 --- src/LogExpert.Resources/Resources.Designer.cs | 9 ++ src/LogExpert.Resources/Resources.de.resx | 3 + src/LogExpert.Resources/Resources.resx | 3 + src/LogExpert.Resources/Resources.zh-CN.resx | 3 + .../Controls/LogWindow/LogWindow.cs | 121 ++++++------------ 5 files changed, 60 insertions(+), 79 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index c73c3534..61748482 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -3405,6 +3405,15 @@ public static string LogWindow_UI_SelectLine_SearchResultNotFound { } } + /// + /// Looks up a localized string similar to Filter failed: {0}. + /// + public static string LogWindow_UI_StatusLineError_FilterFailed { + get { + return ResourceManager.GetString("LogWindow_UI_StatusLineError_FilterFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid regular expression. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 3af85639..adc90283 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -527,6 +527,9 @@ Nicht gefunden: {0} + + Filter fehlgeschlagen: {0} + Ungültige Regular Expression diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 9e7e5919..b6b804cd 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -293,6 +293,9 @@ Freeze left columns until here ({0}) + + Filter failed: {0} + Invalid regular expression diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 732b85fb..846b0563 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -199,6 +199,9 @@ 在此处冻结左侧列 ({0}) + + 筛选失败:{0} + 无效的正则表达式 diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index f888c457..82d0569b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -684,8 +684,6 @@ private void OnButtonSizeChanged (object sender, EventArgs e) private delegate void SelectLineFx (int line, bool triggerSyncCall); - private Action, List, List> FilterFxAction; - //private delegate void FilterFx(FilterParams filterParams, List filterResultLines, List lastFilterResultLines, List filterHitList); private delegate void SetColumnizerFx (ILogLineMemoryColumnizer columnizer); @@ -2662,12 +2660,11 @@ private void RestoreFilterTabs (PersistenceData persistenceData) { var persistFilterParams = data.FilterParams; ReInitFilterParams(persistFilterParams); - List filterResultList = []; - //List lastFilterResultList = new List(); - List filterHitList = []; - Filter(persistFilterParams, filterResultList, _lastFilterLinesList, filterHitList); + // Each restored tab runs its own filter with fresh spread history — its content no + // longer depends on the window's shared history state at restore time. + var filterRun = new SerialFilterEngine().Run(persistFilterParams, new ColumnizerCallback(this), CancellationToken.None); FilterPipe pipe = new(persistFilterParams.Clone(), this); - WritePipeToTab(pipe, filterResultList, data.PersistenceData.TabName, data.PersistenceData); + WritePipeToTab(pipe, [.. filterRun.ResultLines], data.PersistenceData.TabName, data.PersistenceData); } } @@ -4409,95 +4406,61 @@ private async void FilterSearch (string text) _progressEventArgs.Visible = true; SendProgressBarUpdate(); - var settings = ConfigManager.Settings; + IFilterEngine engine = ConfigManager.Settings.Preferences.MultiThreadFilter + ? new ParallelFilterEngine() + : new SerialFilterEngine(); - FilterFxAction = settings.Preferences.MultiThreadFilter ? MultiThreadedFilter : Filter; - var filterFxActionTask = Task.Run(() => FilterFxAction(_filterParams, _filterResultList, _lastFilterLinesList, _filterHitList)).ConfigureAwait(false); - - await filterFxActionTask; - FilterComplete(); - - //fx.BeginInvoke(_filterParams, _filterResultList, _lastFilterLinesList, _filterHitList, FilterComplete, null); - //This needs to be invoked, because there is a potential CrossThreadException - _ = BeginInvoke(CheckForFilterDirty); - } - - private void MultiThreadedFilter (FilterParams filterParams, List filterResultLines, List lastFilterLinesList, List filterHitList) - { ColumnizerCallback callback = new(this); + var progress = new SynchronousProgress(UpdateProgressBar); - FilterStarter fs = new(callback, Environment.ProcessorCount + 2) - { - FilterHitList = _filterHitList, - FilterResultLines = _filterResultList, - LastFilterLinesList = _lastFilterLinesList - }; - - var cancelHandler = new FilterCancelHandler(fs); + // ESC reaches the run through the cancel-handler registry; the engines observe the token. + using CancellationTokenSource filterCts = new(); + var cancelHandler = new FilterRunCancelHandler(filterCts); OnRegisterCancelHandler(cancelHandler); - long startTime = Environment.TickCount; - - fs.DoFilter(filterParams, 0, _logFileReader.LineCount, FilterProgressCallback).GetAwaiter().GetResult(); - long endTime = Environment.TickCount; - - //_logger.Debug($"Multi threaded filter duration: {endTime - startTime} ms.")); - - OnDeRegisterCancelHandler(cancelHandler); - StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_Filter_FilterDurationMs, endTime - startTime)); - } - - private void FilterProgressCallback (int lineCount) - { - UpdateProgressBar(lineCount); - } - - [SupportedOSPlatform("windows")] - private void Filter (FilterParams filterParams, List filterResultLines, List lastFilterLinesList, List filterHitList) - { long startTime = Environment.TickCount; + FilterRun filterRun; try { - filterParams.Reset(); - var lineNum = 0; - //AddFilterLineFx addFx = new AddFilterLineFx(AddFilterLine); - ColumnizerCallback callback = new(this); - while (true) - { - var line = _logFileReader.GetLogLineMemory(lineNum); - if (line == null) - { - break; - } + filterRun = await Task.Run(() => engine.Run(_filterParams, callback, filterCts.Token, progress)).ConfigureAwait(false); + } + finally + { + OnDeRegisterCancelHandler(cancelHandler); + } - callback.LineNum = lineNum; - if (Util.TestFilterCondition(filterParams, line, callback)) - { - AddFilterLine(lineNum, false, filterParams, filterResultLines, lastFilterLinesList, filterHitList); - } + long endTime = Environment.TickCount; - lineNum++; - if (lineNum % PROGRESS_BAR_MODULO == 0) - { - UpdateProgressBar(lineNum); - } + lock (_filterResultList) + { + _filterHitList.AddRange(filterRun.HitLines); + _filterResultList.AddRange(filterRun.ResultLines); + _lastFilterLinesList.AddRange(filterRun.History); + FilterSpread.TrimHistory(_lastFilterLinesList); + } - if (_shouldCancel) - { - break; - } - } + if (filterRun.Outcome == FilterRunOutcome.Failed) + { + StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_FilterFailed, filterRun.Error?.Message)); } - catch (Exception ex) + else { - _ = MessageBox.Show(null, string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_Filter_ExceptionWhileFiltering, ex, ex.StackTrace), Resources.LogExpert_Common_UI_Title_Error); + StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_Filter_FilterDurationMs, endTime - startTime)); } - long endTime = Environment.TickCount; + FilterComplete(); - //_logger.Info($"Single threaded filter duration: {endTime - startTime} ms.")); + //This needs to be invoked, because there is a potential CrossThreadException + _ = BeginInvoke(CheckForFilterDirty); + } - StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_Filter_FilterDurationMs, endTime - startTime)); + /// Bridges the ESC cancel-handler registry to a filter run's token. + private sealed class FilterRunCancelHandler (CancellationTokenSource cts) : IBackgroundProcessCancelHandler + { + public void EscapePressed () + { + cts.Cancel(); + } } [SupportedOSPlatform("windows")] From 691e52fae373208d10b53518635dba03abc2a1a9 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 14:53:22 +0200 Subject: [PATCH 04/18] refactor: migrate the tail filter path to FilterAccumulator (ticket 4) AddFilterLine reduces to a lock around an adopting FilterAccumulator - the accumulator wraps the window''s canonical result/hit/history lists per call (ClearFilterList/ShiftFilterLines replace the instances, so no long-lived accumulator field), which also makes the full run''s History handover seamless: the run seeds the very lists the tail continues. ProcessFilterPipes adopts each pipe''s history the same way; AddHit now returns the expansion so the pipe can write it out. The per-hit spread recipe (Expand -> append -> TrimHistory) no longer appears anywhere in LogWindow.cs, pinned by a new seeded-continuation test on the accumulator. Co-Authored-By: Claude Fable 5 --- .../Classes/Filter/FilterAccumulator.cs | 40 +++++++++++++++---- .../Classes/Filter/SerialFilterEngine.cs | 2 +- .../Filter/FilterAccumulatorTests.cs | 24 +++++++++++ .../Controls/LogWindow/LogWindow.cs | 31 +++++--------- 4 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs b/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs index 02b82a39..ffc93d5b 100644 --- a/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs +++ b/src/LogExpert.Core/Classes/Filter/FilterAccumulator.cs @@ -9,26 +9,52 @@ namespace LogExpert.Core.Classes.Filter; /// public class FilterAccumulator { + public FilterAccumulator () : this([], [], []) + { + } + + /// + /// Adopts existing lists as the accumulator's state — the tail filter path wraps the Log + /// Window's canonical result/hit/history lists (and Filter Pipes their per-pipe history) so a + /// full run's output, including its spread history, is continued in place rather than copied. + /// + public FilterAccumulator (IList resultLines, IList hitLines, IList history) + { + ArgumentNullException.ThrowIfNull(resultLines); + ArgumentNullException.ThrowIfNull(hitLines); + ArgumentNullException.ThrowIfNull(history); + + ResultLines = resultLines; + HitLines = hitLines; + History = history; + } + /// Filter result lines: every hit plus its spread context, in accumulation order. - public List ResultLines { get; } = []; + public IList ResultLines { get; } /// The hit lines only, without spread. - public List HitLines { get; } = []; + public IList HitLines { get; } /// The trailing dedup window (spread history) recent expansions were checked against. - public List History { get; } = []; + public IList History { get; } /// /// Records a filter hit: adds it to and appends its spread expansion /// (deduplicated against , clamped to the file's line range) to - /// . + /// . Returns the expansion so callers that emit it (Filter Pipes) + /// need not diff the lists. /// - public void AddHit (int lineNum, int spreadBefore, int spreadBehind, int lineCount) + public IList AddHit (int lineNum, int spreadBefore, int spreadBehind, int lineCount) { HitLines.Add(lineNum); var expanded = FilterSpread.Expand(lineNum, spreadBefore, spreadBehind, lineCount, History); - ResultLines.AddRange(expanded); - History.AddRange(expanded); + foreach (var line in expanded) + { + ResultLines.Add(line); + History.Add(line); + } + FilterSpread.TrimHistory(History); + return expanded; } } diff --git a/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs index 9b26843c..f6d246bc 100644 --- a/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs +++ b/src/LogExpert.Core/Classes/Filter/SerialFilterEngine.cs @@ -83,7 +83,7 @@ private static FilterRun Snapshot (FilterAccumulator accumulator, FilterRunOutco error); } - private static List Normalize (List lines) + private static List Normalize (IList lines) { var normalized = new List(new SortedSet(lines)); return normalized; diff --git a/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs b/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs index 7ad51373..51d0809b 100644 --- a/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs +++ b/src/LogExpert.Tests/Filter/FilterAccumulatorTests.cs @@ -13,6 +13,30 @@ public class FilterAccumulatorTests /// ' accumulation-recipe guard: hits 5, 7 and 50 with /// spread 2/2 in a 100-line file. /// + /// + /// The tail filter path continues a full run: the accumulator adopts the window's canonical + /// lists, so a history seeded by the run (here: the run already emitted 3..7 for a hit at 5) + /// dedups subsequent tail hits exactly as if one accumulation had never stopped. + /// + [Test] + public void AdoptedLists_HistorySeededByFullRun_DedupsContinuationHits () + { + List resultLines = [3, 4, 5, 6, 7]; + List hitLines = [5]; + List history = [3, 4, 5, 6, 7]; + var accumulator = new FilterAccumulator(resultLines, hitLines, history); + + var expansion = accumulator.AddHit(7, spreadBefore: 2, spreadBehind: 2, lineCount: 100); + + Assert.Multiple(() => + { + // Hit 7 contributes only 8,9 — 5..7 were already taken by the seeded run. + Assert.That(expansion, Is.EqualTo(new[] { 8, 9 }), "AddHit returns the hit's expansion (Filter Pipes write it out)"); + Assert.That(resultLines, Is.EqualTo(new[] { 3, 4, 5, 6, 7, 8, 9 }), "the adopted list instance is the accumulator's state"); + Assert.That(hitLines, Is.EqualTo(new[] { 5, 7 })); + }); + } + [Test] public void AddHit_OverlappingHitSequence_ProducesPinnedResultHitAndHistoryLists () { diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 82d0569b..5945b58f 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -3201,7 +3201,7 @@ private void CheckFilterAndHighlight (LogEventArgs e) if (Util.TestFilterCondition(_filterParams, line, callback)) { filterLineAdded = true; - AddFilterLine(i, false, _filterParams, _filterResultList, _lastFilterLinesList, _filterHitList); + AddFilterLine(i, false); } } @@ -4464,28 +4464,20 @@ public void EscapePressed () } [SupportedOSPlatform("windows")] - private void AddFilterLine (int lineNum, bool immediate, FilterParams filterParams, List filterResultLines, List lastFilterLinesList, List filterHitList) + private void AddFilterLine (int lineNum, bool immediate) { - int count; lock (_filterResultList) { - filterHitList.Add(lineNum); - var filterResult = FilterSpread.Expand(lineNum, filterParams.SpreadBefore, filterParams.SpreadBehind, _logFileReader.LineCount, lastFilterLinesList); - filterResultLines.AddRange(filterResult); - count = filterResultLines.Count; - lastFilterLinesList.AddRange(filterResult); - FilterSpread.TrimHistory(lastFilterLinesList); + // Adopts the window's canonical lists per call — ClearFilterList/ShiftFilterLines + // replace the instances, so the accumulator must not outlive them. + new FilterAccumulator(_filterResultList, _filterHitList, _lastFilterLinesList) + .AddHit(lineNum, _filterParams.SpreadBefore, _filterParams.SpreadBehind, _logFileReader.LineCount); } if (immediate) { TriggerFilterLineGuiUpdate(); } - else if (lineNum % PROGRESS_BAR_MODULO == 0) - { - //FunctionWith1IntParam fx = new FunctionWith1IntParam(UpdateFilterCountLabel); - //this.Invoke(fx, new object[] { count}); - } } [SupportedOSPlatform("windows")] @@ -5068,17 +5060,15 @@ private void ProcessFilterPipes (int lineNum) continue; } - //long startTime = Environment.TickCount; if (Util.TestFilterCondition(pipe.FilterParams, searchLine, callback)) { - var filterResult = FilterSpread.Expand(lineNum, pipe.FilterParams.SpreadBefore, pipe.FilterParams.SpreadBehind, _logFileReader.LineCount, pipe.LastLinesHistoryList); + // Adopts the pipe's history; result/hit lists are not tracked per pipe. + var filterResult = new FilterAccumulator([], [], pipe.LastLinesHistoryList) + .AddHit(lineNum, pipe.FilterParams.SpreadBefore, pipe.FilterParams.SpreadBehind, _logFileReader.LineCount); pipe.OpenFile(); foreach (var line in filterResult) { - pipe.LastLinesHistoryList.Add(line); - FilterSpread.TrimHistory(pipe.LastLinesHistoryList); - var textLine = _logFileReader.GetLogLineMemory(line); var fileOk = pipe.WriteToPipe(textLine, line); if (!fileOk) @@ -5089,9 +5079,6 @@ private void ProcessFilterPipes (int lineNum) pipe.CloseFile(); } - - //long endTime = Environment.TickCount; - //_logger.logDebug("ProcessFilterPipes(" + lineNum + ") duration: " + ((endTime - startTime))); } } From b7a293a39333eb1b3bb902d085fdbe6bc43efcd1 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 15:00:58 +0200 Subject: [PATCH 05/18] refactor: retire _shouldCancel - window CTS + per-job linked tokens (ticket 5) The shared mutable cancel flag and its whole supporting cast are gone: _shouldCancel, FilterCancelHandler, FilterStarter._shouldStop and Filter.ShouldCancel. A window-lifetime CTS is cancelled on CloseLogWindow; each job (filter run, Filter Pipes write, pattern statistics; Log Search''s _searchCts joins the model) creates a linked CTS at start and registers it in the per-window cancel-handler registry, which ESC fans out through. Reload and dead-file fire the registry instead of the flag - so they now cancel a running parallel filter too (previously only ESC could), while a dead file keeps the window lifetime alive for respawn. Filter/FilterStarter observe the token directly. SelectLine''s consume-the-flag gate is deleted; its search- epilogue duties (_isSearching reset, status clear) moved to SearchComplete. WriteFilterToTabFinished takes the cancelled outcome as a parameter instead of re-reading shared state. grep _shouldCancel over src/ returns nothing. Co-Authored-By: Claude Fable 5 --- src/LogExpert.Core/Classes/Filter/Filter.cs | 9 +- .../Classes/Filter/FilterCancelHandler.cs | 30 ----- .../Classes/Filter/FilterStarter.cs | 38 +----- .../Classes/Filter/ParallelFilterEngine.cs | 4 +- .../Controls/LogWindow/LogWindow.cs | 109 ++++++++++-------- 5 files changed, 69 insertions(+), 121 deletions(-) delete mode 100644 src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs diff --git a/src/LogExpert.Core/Classes/Filter/Filter.cs b/src/LogExpert.Core/Classes/Filter/Filter.cs index f2b611bf..c765cede 100644 --- a/src/LogExpert.Core/Classes/Filter/Filter.cs +++ b/src/LogExpert.Core/Classes/Filter/Filter.cs @@ -35,22 +35,21 @@ public Filter (ColumnizerCallback callback) public List FilterResultLines { get; } public List LastFilterLinesList { get; } public List FilterHitList { get; } - public bool ShouldCancel { get; set; } #endregion #region Public methods - public int DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback) + public int DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback, CancellationToken cancellationToken) { - return DoFilter(filterParams, startLine, maxCount, FilterResultLines, LastFilterLinesList, FilterHitList, progressCallback); + return DoFilter(filterParams, startLine, maxCount, FilterResultLines, LastFilterLinesList, FilterHitList, progressCallback, cancellationToken); } #endregion #region Private Methods - private int DoFilter (FilterParams filterParams, int startLine, int maxCount, List filterResultLines, List lastFilterLinesList, List filterHitList, ProgressCallback progressCallback) + private int DoFilter (FilterParams filterParams, int startLine, int maxCount, List filterResultLines, List lastFilterLinesList, List filterHitList, ProgressCallback progressCallback, CancellationToken cancellationToken) { var lineNum = startLine; var count = 0; @@ -60,7 +59,7 @@ private int DoFilter (FilterParams filterParams, int startLine, int maxCount, Li { filterParams.Reset(); - while ((count++ < maxCount || filterParams.IsInRange) && !ShouldCancel) + while ((count++ < maxCount || filterParams.IsInRange) && !cancellationToken.IsCancellationRequested) { if (lineNum >= _callback.GetLineCount()) { diff --git a/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs b/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs deleted file mode 100644 index 5da892ee..00000000 --- a/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Globalization; - -using LogExpert.Core.Interfaces; - -using NLog; - -namespace LogExpert.Core.Classes.Filter; - -public class FilterCancelHandler (FilterStarter filterStarter) : IBackgroundProcessCancelHandler -{ - private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); - #region Fields - - private readonly FilterStarter _filterStarter = filterStarter; - - #endregion - #region cTor - - #endregion - - #region Public methods - - public void EscapePressed () - { - _logger.Info(CultureInfo.InvariantCulture, "FilterCancelHandler called."); - _filterStarter.CancelFilter(); - } - - #endregion -} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs index aa4d663b..444cbf4a 100644 --- a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs +++ b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs @@ -19,13 +19,10 @@ public class FilterStarter private readonly List _filterReadyList; private readonly SortedDictionary _filterResultDict; - private readonly List _filterWorkerList; - private readonly SortedDictionary _lastFilterLinesDict; private ProgressCallback _progressCallback; private int _progressLineCount; - private bool _shouldStop; #endregion @@ -38,7 +35,6 @@ public FilterStarter (ColumnizerCallback callback, int minThreads) LastFilterLinesList = []; FilterHitList = []; _filterReadyList = []; - _filterWorkerList = []; _filterHitDict = []; _filterResultDict = []; _lastFilterLinesDict = []; @@ -61,7 +57,7 @@ public FilterStarter (ColumnizerCallback callback, int minThreads) #region Public methods - public async Task DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback) + public async Task DoFilter (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback, CancellationToken cancellationToken) { FilterResultLines.Clear(); LastFilterLinesList.Clear(); @@ -70,8 +66,6 @@ public async Task DoFilter (FilterParams filterParams, int startLine, int maxCou _filterReadyList.Clear(); _filterResultDict.Clear(); _lastFilterLinesDict.Clear(); - _filterWorkerList.Clear(); - _shouldStop = false; var interval = maxCount / ThreadCount; @@ -102,7 +96,7 @@ public async Task DoFilter (FilterParams filterParams, int startLine, int maxCou var capturedStartLine = workStartLine; var capturedInterval = interval; - tasks.Add(Task.Run(() => DoWork(filterParams, capturedStartLine, capturedInterval, ThreadProgressCallback))); + tasks.Add(Task.Run(() => DoWork(filterParams, capturedStartLine, capturedInterval, ThreadProgressCallback, cancellationToken))); workStartLine += interval; } @@ -110,23 +104,6 @@ public async Task DoFilter (FilterParams filterParams, int startLine, int maxCou MergeResults(); } - /// - /// Requests the FilterStarter to stop all filter threads. Call this from another thread (e.g. GUI). The function returns - /// immediately without waiting for filter end. - /// - public void CancelFilter () - { - _shouldStop = true; - lock (_filterWorkerList) - { - _logger.Info(CultureInfo.InvariantCulture, "Filter cancel requested. Stopping all {0} threads.", _filterWorkerList.Count); - foreach (var filter in _filterWorkerList) - { - filter.ShouldCancel = true; - } - } - } - #endregion #region Private Methods @@ -137,22 +114,17 @@ private void ThreadProgressCallback (int lineCount) _progressCallback(count); } - private Filter DoWork (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback) + private Filter DoWork (FilterParams filterParams, int startLine, int maxCount, ProgressCallback progressCallback, CancellationToken cancellationToken) { _logger.Info(CultureInfo.InvariantCulture, "Started Filter worker [{0}] for line {1}", Environment.CurrentManagedThreadId, startLine); // Give every thread own copies of ColumnizerCallback and FilterParams, because the state of the objects changes while filtering var threadFilterParams = filterParams.CloneWithCurrentColumnizer(); Filter filter = new(_callback.Clone()); - lock (_filterWorkerList) - { - _filterWorkerList.Add(filter); - } - if (!_shouldStop) + if (!cancellationToken.IsCancellationRequested) { - - _ = filter.DoFilter(threadFilterParams, startLine, maxCount, progressCallback); + _ = filter.DoFilter(threadFilterParams, startLine, maxCount, progressCallback, cancellationToken); _logger.Info(CultureInfo.InvariantCulture, "Filter worker [{0}] for line {1} has completed.", Environment.CurrentManagedThreadId, startLine); lock (_filterReadyList) diff --git a/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs b/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs index cca84467..8177c85e 100644 --- a/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs +++ b/src/LogExpert.Core/Classes/Filter/ParallelFilterEngine.cs @@ -45,12 +45,10 @@ public FilterRun Run (FilterParams filterParams, ColumnizerCallback callback, Ca FilterStarter filterStarter = new(callback, _chunkCount); - using var cancelRegistration = cancellationToken.Register(filterStarter.CancelFilter); - try { filterStarter - .DoFilter(snapshot, 0, lineCount, count => progress?.Report(count)) + .DoFilter(snapshot, 0, lineCount, count => progress?.Report(count), cancellationToken) .GetAwaiter() .GetResult(); } diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 5945b58f..f0ff8031 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -154,7 +154,10 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private SortedList _rowHeightList = []; private int _selectedCol; // set by context menu event for column headers only private bool _shouldCallTimeSync; - private bool _shouldCancel; + /// Window-lifetime cancellation: per-job token sources link to it, so closing the + /// window cancels every running job. Live jobs are also registered in the cancel-handler + /// registry, which ESC and reload fan out through. + private readonly CancellationTokenSource _windowCts = new(); private bool _isClosing; // set once CloseLogWindow starts tearing down; suppresses grid CellValueNeeded callbacks private bool _shouldTimestampDisplaySyncingCancel; private bool _showAdvanced; @@ -687,7 +690,6 @@ private void OnButtonSizeChanged (object sender, EventArgs e) private delegate void SetColumnizerFx (ILogLineMemoryColumnizer columnizer); - private delegate void WriteFilterToTabFinishedFx (FilterPipe pipe, string namePrefix, PersistenceData persistenceData); private delegate void SetBookmarkFx (int lineNum, string comment); @@ -2705,7 +2707,7 @@ private void EnterLoadFileStatus () SendProgressBarUpdate(); _isLoading = true; - _shouldCancel = true; + FireCancelHandlers(); // reload cancels the jobs of the old content, not the window lifetime _searchCts?.Cancel(); ClearFilterList(); ClearBookmarkList(); @@ -2745,7 +2747,7 @@ private void LogfileDead () _statusEventArgs.LineCount = 0; _statusEventArgs.CurrentLineNum = 0; SendStatusLineUpdate(); - _shouldCancel = true; + FireCancelHandlers(); // a dead file may respawn — cancel its jobs, keep the window lifetime _searchCts?.Cancel(); ClearFilterList(); ClearBookmarkList(); @@ -2938,7 +2940,6 @@ private void LoadingFinished () StatusLineText(string.Empty); _logFileReader.FileSizeChanged += OnFileSizeChanged; _isLoading = false; - _shouldCancel = false; dataGridView.SuspendLayout(); dataGridView.RowCount = _logFileReader.LineCount; dataGridView.CurrentCellChanged += OnDataGridViewCurrentCellChanged; @@ -4062,16 +4063,6 @@ private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll) try { _shouldCallTimeSync = triggerSyncCall; - var wasCancelled = _shouldCancel; - _shouldCancel = false; - _isSearching = false; - StatusLineText(string.Empty); - _guiStateArgs.MenuEnabled = true; - - if (wasCancelled) - { - return; - } if (lineNum < 0) { @@ -4394,7 +4385,6 @@ private async void FilterSearch (string text) //ConfigManager.SaveFilterParams(this.filterParams); ConfigManager.Settings.FilterParams = _filterParams; // wozu eigentlich? sinnlos seit MDI? - _shouldCancel = false; _isSearching = true; StatusLineText(Resources.LogWindow_UI_StatusLineText_FilterSearch_Filtering); btnfilterSearch.Enabled = false; @@ -4414,7 +4404,7 @@ private async void FilterSearch (string text) var progress = new SynchronousProgress(UpdateProgressBar); // ESC reaches the run through the cancel-handler registry; the engines observe the token. - using CancellationTokenSource filterCts = new(); + using var filterCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); var cancelHandler = new FilterRunCancelHandler(filterCts); OnRegisterCancelHandler(cancelHandler); @@ -4924,7 +4914,11 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n _progressEventArgs.Visible = true; _ = Invoke(new MethodInvoker(SendProgressBarUpdate)); _isSearching = true; - _shouldCancel = false; + + // Per-job token, linked to the window's lifetime; ESC reaches it via the registry. + using var pipeCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); + var cancelHandler = new FilterRunCancelHandler(pipeCts); + OnRegisterCancelHandler(cancelHandler); lock (_filterPipeList) { @@ -4936,37 +4930,45 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n pipe.OpenFile(); LogExpertCallback callback = new(this); - foreach (var i in lineNumberList) + try { - if (_shouldCancel) + foreach (var i in lineNumberList) { - break; - } + if (pipeCts.Token.IsCancellationRequested) + { + break; + } - var line = _logFileReader.GetLogLineMemory(i); - if (CurrentColumnizer is ILogLineMemoryXmlColumnizer) - { - callback.LineNum = i; - line = (CurrentColumnizer as ILogLineMemoryXmlColumnizer).GetLineTextForClipboard(line, callback); - } + var line = _logFileReader.GetLogLineMemory(i); + if (CurrentColumnizer is ILogLineMemoryXmlColumnizer) + { + callback.LineNum = i; + line = (CurrentColumnizer as ILogLineMemoryXmlColumnizer).GetLineTextForClipboard(line, callback); + } - _ = pipe.WriteToPipe(line, i); - if (++count % PROGRESS_BAR_MODULO == 0) - { - _progressEventArgs.Value = count; - _ = Invoke(new MethodInvoker(SendProgressBarUpdate)); + _ = pipe.WriteToPipe(line, i); + if (++count % PROGRESS_BAR_MODULO == 0) + { + _progressEventArgs.Value = count; + _ = Invoke(new MethodInvoker(SendProgressBarUpdate)); + } } } + finally + { + OnDeRegisterCancelHandler(cancelHandler); + } pipe.CloseFile(); - _ = Invoke(new WriteFilterToTabFinishedFx(WriteFilterToTabFinished), pipe, name, persistenceData); + var wasCancelled = pipeCts.IsCancellationRequested; + Invoke(() => WriteFilterToTabFinished(pipe, name, persistenceData, wasCancelled)); } [SupportedOSPlatform("windows")] - private void WriteFilterToTabFinished (FilterPipe pipe, string name, PersistenceData persistenceData) + private void WriteFilterToTabFinished (FilterPipe pipe, string name, PersistenceData persistenceData, bool wasCancelled) { _isSearching = false; - if (!_shouldCancel) + if (!wasCancelled) { var title = name; ILogLineMemoryColumnizer preProcessColumnizer = null; @@ -5014,7 +5016,7 @@ internal void WritePipeTab (IList lineEntryList, string title) } pipe.CloseFile(); - _ = Invoke(new WriteFilterToTabFinishedFx(WriteFilterToTabFinished), [pipe, title, null]); + Invoke(() => WriteFilterToTabFinished(pipe, title, null, wasCancelled: false)); } [SupportedOSPlatform("windows")] @@ -5367,9 +5369,15 @@ private void TestStatistic (PatternArgs patternArgs) List blockList = []; var blockId = 0; _isSearching = true; - _shouldCancel = false; + + // Per-job token, linked to the window's lifetime; ESC reaches it via the registry. + using var patternCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); + var cancelHandler = new FilterRunCancelHandler(patternCts); + OnRegisterCancelHandler(cancelHandler); + var cancellationToken = patternCts.Token; + var searchLine = -1; - for (var i = beginLine; i < num && !_shouldCancel; ++i) + for (var i = beginLine; i < num && !cancellationToken.IsCancellationRequested; ++i) { if (processedLinesDict.ContainsKey(i)) { @@ -5383,14 +5391,15 @@ private void TestStatistic (PatternArgs patternArgs) //bool firstBlock = true; searchLine++; UpdateProgressBar(searchLine); - while (!_shouldCancel && + while (!cancellationToken.IsCancellationRequested && (block = DetectBlock(i, searchLine, maxBlockLen, _patternArgs.MaxDiffInBlock, _patternArgs.MaxMisses, - processedLinesDict) + processedLinesDict, + cancellationToken) ) != null) { //_logger.Debug($"Found block: {block}"); @@ -5429,6 +5438,7 @@ private void TestStatistic (PatternArgs patternArgs) blockId++; } + OnDeRegisterCancelHandler(cancelHandler); _isSearching = false; _progressEventArgs.MinValue = 0; _progressEventArgs.MaxValue = 0; @@ -5469,7 +5479,7 @@ private static void AddBlockTargetLinesToDict (Dictionary dict, Patter // return null; //} - private PatternBlock DetectBlock (int startNum, int startLineToSearch, int maxBlockLen, int maxDiffInBlock, int maxMisses, Dictionary processedLinesDict) + private PatternBlock DetectBlock (int startNum, int startLineToSearch, int maxBlockLen, int maxDiffInBlock, int maxMisses, Dictionary processedLinesDict, CancellationToken cancellationToken) { var targetLine = FindSimilarLine(startNum, startLineToSearch, processedLinesDict); if (targetLine == -1) @@ -5494,7 +5504,7 @@ private PatternBlock DetectBlock (int startNum, int startLineToSearch, int maxBl }; block.QualityInfoList[targetLine] = qi; - while (!_shouldCancel) + while (!cancellationToken.IsCancellationRequested) { srcLine++; len++; @@ -6330,7 +6340,7 @@ public void CloseLogWindow () StopTimespreadThread(); StopTimestampSyncThread(); StopLogEventWorkerThread(); - _shouldCancel = true; + _windowCts.Cancel(); // window lifetime ends: every linked job token cancels _searchCts?.Cancel(); if (_logFileReader != null) @@ -6832,7 +6842,6 @@ public void StartSearch () _currentSearchParams = searchParams; // remember for async "not found" messages _isSearching = true; - _shouldCancel = false; // shared flag: SelectLine's cancel gate and the filter loops read it StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel); _progressEventArgs.MinValue = 0; @@ -6841,13 +6850,12 @@ public void StartSearch () _progressEventArgs.Visible = true; SendProgressBarUpdate(); - // Replace the token source only after a consumed cancellation (mirrors the old - // _shouldCancel = false reset); a still-running previous search keeps sharing it, - // so one ESC cancels every running search. + // Replace the token source only after a consumed cancellation; a still-running previous + // search keeps sharing it, so one ESC cancels every running search. if (_searchCts == null || _searchCts.IsCancellationRequested) { _searchCts?.Dispose(); - _searchCts = new CancellationTokenSource(); + _searchCts = CancellationTokenSource.CreateLinkedTokenSource(_windowCts.Token); } var cancellationToken = _searchCts.Token; @@ -6871,12 +6879,14 @@ private void SearchComplete (Task task) { _ = Invoke(new MethodInvoker(ResetProgressBar)); var result = task.Result; + _isSearching = false; // was SelectLine's job when it doubled as the search epilogue _guiStateArgs.MenuEnabled = true; GuiStateUpdate(this, _guiStateArgs); switch (result.Outcome) { case SearchOutcome.Found: + StatusLineText(string.Empty); _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), result.LineNumber, true); break; @@ -6960,7 +6970,6 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) { if (_isSearching) { - _shouldCancel = true; // shared flag: filter loops and SelectLine's cancel gate read it _searchCts?.Cancel(); } From b51874d957af3b0b34e6d59d3900c09613033f79 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 15:03:56 +0200 Subject: [PATCH 06/18] docs: Filter Engine glossary entries - contract sweep (ticket 6) CONTEXT.md gains Filter Engine, Filter Run and Filter Accumulator as canonical terms, Avoid entries for multi-threaded filter as a concept name and the deleted FilterFx delegate, and the Tail trigger path entry now reflects the unified loop and the by-construction audio invariant. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index dde30b65..88332402 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,9 +16,12 @@ meaning; do not redefine them locally. - **Action Entry** (`ActionEntry`) — Plugin name + parameters bound to the Plugin trigger of a Highlight Entry. - **Tail trigger path** — The single code path in `LogWindow.CheckFilterAndHighlight` - that evaluates highlight entries against *newly appended* lines. Triggers - that have user-perceivable side effects (currently: Audio Alert) fire **only** - on this path; bulk/scanner paths intentionally skip them. + (one unified loop since the Filter Engine extraction) that evaluates + highlight entries against *newly appended* lines. Triggers that have + user-perceivable side effects (currently: Audio Alert) fire **only** on + this path; bulk/scanner paths intentionally skip them — enforced by + construction, since the bulk `HighlightBookmarkScanner` has no access to + the side-effecting triggers. ## Audio alerts @@ -88,6 +91,25 @@ bare "session file" when you mean the workspace (that's a **Session**). ## Filtering +- **Filter Engine** (`IFilterEngine`) — A Core module that executes one full + **Filter Run** over a log file. Two engines implement the seam — the + **Serial** and the **Parallel** engine (`SerialFilterEngine`, + `ParallelFilterEngine`, the latter wrapping `FilterStarter`'s + chunk-and-merge) — selected by the `MultiThreadFilter` preference and held + to an identical, equivalence-test-pinned contract: sorted-ascending + duplicate-free output, `FilterParams` and line count snapshotted at entry, + failures and cancellation narrated as outcomes, never thrown or shown by + the engine. +- **Filter Run** (`FilterRun`) — One execution of a Filter Engine and its + result: result lines, hit lines, the spread history (handed to the tail + path to continue from), and an outcome — Completed, Cancelled (partial + lists stand), or Failed (carries the exception; the Log Window narrates it + on the status line). +- **Filter Accumulator** (`FilterAccumulator`) — The single owner of the + per-hit accumulation recipe (hit → spread expansion → append to + results/history → trim history). Used inside the Serial engine, per line by + the Log Window's tail filter path, and per hit by Filter Pipes — all three + adopt existing lists, so a Filter Run's history is continued in place. - **Filter Spread** — Context expansion around a filter hit: **Back Spread** (`FilterParams.SpreadBefore`) lines before and **Fore Spread** (`FilterParams.SpreadBehind`) lines after the hit are included in the @@ -102,7 +124,9 @@ bare "session file" when you mean the workspace (that's a **Session**). *Avoid*: "spread" alone when ambiguous (say **Back Spread** / **Fore Spread**), "additional filter results" (the old internal name — use -**Filter Spread**). +**Filter Spread**), "multi-threaded filter" as a concept name (it is a +preference selecting the Parallel **Filter Engine**), "FilterFx" (legacy +delegate name, deleted). ## Log Search From e120823a30c14e1e343e09a47c5f565c85827f95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 14:16:58 +0000 Subject: [PATCH 07/18] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 5283c38f..1f1c584a 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-09 17:28:10 UTC + /// Generated: 2026-07-11 14:16:57 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "6AA4B522A288F18B940A492886FA3BA922370EA8E41EB55690DC9008402BF4EF", + ["AutoColumnizer.dll"] = "8263694C84BC9CA6EEA8E50AA4F0062671A5F72698421F0001D752D0C0EBC286", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "C5F6D38808A045A08781AE19E34B0693417CFA234163399F9FDE0A29D3D44A91", - ["CsvColumnizer.dll (x86)"] = "C5F6D38808A045A08781AE19E34B0693417CFA234163399F9FDE0A29D3D44A91", - ["DefaultPlugins.dll"] = "980DD4FF54F973A63DA1AE4374B593BE676EC7AD5CC99E8DAC7836E9DCC488CC", - ["FlashIconHighlighter.dll"] = "DE28DCB05D61670FA35A1442D27A37438B48E0A30D0E31BE3255E25557114394", - ["GlassfishColumnizer.dll"] = "DD48B9EA3DEC493C6ACF7B9E77EE3DACF09EBC678B2AE56D0AF512F1C102CFA8", - ["JsonColumnizer.dll"] = "4F9993D74701EBBE953774D8B05171EC66CA7DCA828DD3D3BC66036899B7B2A4", - ["JsonCompactColumnizer.dll"] = "ADC6F2B3EA0FCE16F3FE7E3E0C753359736A9414FEC54C268B690B812F4017AB", - ["Log4jXmlColumnizer.dll"] = "5D19814EE3B93C95E90B1CD351CD20A164F3003D6A02D884F50BA80EE634C7BD", - ["LogExpert.Resources.dll"] = "5F4DC82B25E08CB824AFD21E5DF4C0B11348FBEA964A8EFA3131CBBA09341B42", + ["CsvColumnizer.dll"] = "7386355082041FF68E8963842648609886F78782B33F08B7D48CEDD8926856B5", + ["CsvColumnizer.dll (x86)"] = "7386355082041FF68E8963842648609886F78782B33F08B7D48CEDD8926856B5", + ["DefaultPlugins.dll"] = "EB57592DAFEA959FA879E7C059A38241C38ACB19B23C943957445726F9E0AB40", + ["FlashIconHighlighter.dll"] = "6053C09B577A19FEF476749A04E1FC1E3992CF5ADC611ABDCC014846B4C056B6", + ["GlassfishColumnizer.dll"] = "6828CDF9BF8361D154AC952173A00DBF7B0D03CAF9B04B228AAF2051E57CBAA5", + ["JsonColumnizer.dll"] = "6F5AF859728EBE8C702886CD2FAEC60D6FD56D5ED65CDF790682C18DA41299E2", + ["JsonCompactColumnizer.dll"] = "5715FCD032F1D444981C09004CD194B23430AEAB210B6D567FE2974CDF78E45F", + ["Log4jXmlColumnizer.dll"] = "1AD9FB2C0E4B9712F4E580FE8064E9066B756A43D306865BC7A1842B1326FFE7", + ["LogExpert.Resources.dll"] = "E8E72B3C9DEC05BA770209494B1BC428390F230909DCA9AD073DAD7766D1EBD6", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "66A5F491FFFDE12A26FB22A0CD7943B3276458082751D5961A126D7426FEFEFF", - ["SftpFileSystem.dll"] = "4CB88C070876F239CA274D80C37B7B9399BECBDE904E0EBFF4E1AE6033702BFC", - ["SftpFileSystem.dll (x86)"] = "A510B166B70B6CD51CF2040F66A0F3CA293C62517B95D2A71ED104D55C3FC937", - ["SftpFileSystem.Resources.dll"] = "B57839C99A7D90F7BB8A482EBDFF3490FE9BE47C10D07DACAD2FBB52D9E4F9B5", - ["SftpFileSystem.Resources.dll (x86)"] = "B57839C99A7D90F7BB8A482EBDFF3490FE9BE47C10D07DACAD2FBB52D9E4F9B5", + ["RegexColumnizer.dll"] = "EBAE38928E1A8A1E56BBC28C424444A695981EADB39A45558598B744DBD0C302", + ["SftpFileSystem.dll"] = "BF1C40AD11A0E10A9D79686207BDA05640EC822AFFBC23E6A439F3863451F751", + ["SftpFileSystem.dll (x86)"] = "1D6946128B5134D1A35692429F48C901AFE18AE929323EA77E84ECA547E4D11B", + ["SftpFileSystem.Resources.dll"] = "7EA6B633C799F02F4C30F7B402F020168314A9D0AB7A2EF4D0B322B2CE9EDB7C", + ["SftpFileSystem.Resources.dll (x86)"] = "7EA6B633C799F02F4C30F7B402F020168314A9D0AB7A2EF4D0B322B2CE9EDB7C", }; } From 4ca9490446d30bfc6e4e1d7ea1b1ff00006977f8 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 16:26:25 +0200 Subject: [PATCH 08/18] fix: guard filter epilogues against close-mid-run (smoke finding) Ctrl+W during a filter run threw InvalidOperationException from FilterSearch''s continuation: the run''s token is linked to the window lifetime, so closing now cancels the run promptly - and the continuation then called BeginInvoke(CheckForFilterDirty) on a control whose handle was already destroyed. FilterComplete had a disposal guard; the surrounding epilogue did not. Both the filter run''s and the pipe write''s post-run UI epilogues now return early when the window is disposed, disposing, waiting for close, or handleless. Found by the manual smoke (close tab mid-filter). Co-Authored-By: Claude Fable 5 --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index f0ff8031..2aad635c 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -4429,6 +4429,13 @@ private async void FilterSearch (string text) FilterSpread.TrimHistory(_lastFilterLinesList); } + // Closing the window cancels the run (linked token), so this continuation also fires + // mid-close — with the handle already destroyed, narration and BeginInvoke would throw. + if (IsDisposed || Disposing || _waitingForClose || !IsHandleCreated) + { + return; + } + if (filterRun.Outcome == FilterRunOutcome.Failed) { StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_FilterFailed, filterRun.Error?.Message)); @@ -4961,6 +4968,14 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n pipe.CloseFile(); var wasCancelled = pipeCts.IsCancellationRequested; + + // Same close-mid-job hazard as the filter run's epilogue: skip the UI finish when the + // window went away while writing. + if (IsDisposed || Disposing || _waitingForClose || !IsHandleCreated) + { + return; + } + Invoke(() => WriteFilterToTabFinished(pipe, name, persistenceData, wasCancelled)); } From 28ed4b48af7c919cec698dfd157a42963b8a27e5 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 16:34:30 +0200 Subject: [PATCH 09/18] fix: clear the status bar when a tab closes mid-job (smoke finding) After close-mid-filter, the main window kept showing Filtering... and a frozen progress bar: the parent status bar only updates from the current tab''s events, and the cancelled job''s UI epilogue now (correctly) skips a disposed window - so nobody reset it. CloseLogWindow clears the status line and hides the progress bar itself, right after cancelling its jobs, while its events are still wired to the parent. Co-Authored-By: Claude Fable 5 --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 2aad635c..b02af101 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -6358,6 +6358,14 @@ public void CloseLogWindow () _windowCts.Cancel(); // window lifetime ends: every linked job token cancels _searchCts?.Cancel(); + // Jobs cancelled by the close skip their UI epilogues (the handle is going away), so the + // closing window clears the shared status line and progress bar itself, while its events + // are still wired to the parent. + StatusLineText(string.Empty); + _progressEventArgs.Visible = false; + _progressEventArgs.Value = _progressEventArgs.MaxValue; + SendProgressBarUpdate(); + if (_logFileReader != null) { UnRegisterLogFileReaderEvents(); From 192fd269658b59d796cb8c712f54f3ac105e7796 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 16:50:08 +0200 Subject: [PATCH 10/18] fix: empty filter panel after session restore (smoke finding) The close-mid-run guard used !IsHandleCreated as a proxy for the window going away - but a session-restored background tab legitimately runs its restore filter before its own handle exists (WinForms marshals Invoke through the parent), so the guard skipped FilterComplete and the filter grid was never populated even though the result lists were full. The discriminator is now _isClosing, which CloseLogWindow sets synchronously before cancelling the window token, so close-cancelled continuations still bail while restore-time runs reach the grid update. Same correction in the pipe-write epilogue guard. Co-Authored-By: Claude Fable 5 --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index b02af101..de7afc9b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -4431,7 +4431,10 @@ private async void FilterSearch (string text) // Closing the window cancels the run (linked token), so this continuation also fires // mid-close — with the handle already destroyed, narration and BeginInvoke would throw. - if (IsDisposed || Disposing || _waitingForClose || !IsHandleCreated) + // _isClosing (set before the cancel) is the discriminator, NOT IsHandleCreated: a session- + // restored background tab runs this with its own handle not yet created, marshaling + // through the parent — and must still reach FilterComplete to populate the filter grid. + if (IsDisposed || Disposing || _waitingForClose || _isClosing) { return; } @@ -4970,8 +4973,8 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n var wasCancelled = pipeCts.IsCancellationRequested; // Same close-mid-job hazard as the filter run's epilogue: skip the UI finish when the - // window went away while writing. - if (IsDisposed || Disposing || _waitingForClose || !IsHandleCreated) + // window went away while writing (_isClosing, not IsHandleCreated — see FilterSearch). + if (IsDisposed || Disposing || _waitingForClose || _isClosing) { return; } From 0118dbddad739739d43e76524cd25c7bf9be7653 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 17:01:37 +0200 Subject: [PATCH 11/18] feat: re-wire the orphaned Pattern analysis menu item (spike) LogWindow.PatternStatistic() and the whole PatternWindow/TestStatistic feature had no UI entry point - no menu ever called it, so the feature was unreachable dead code. A View/Navigate > Pattern analysis... item (localized en/de/zh-CN, enabled with the other per-file items) now opens it again. Spike wiring to evaluate the long-orphaned feature: if it turns out rotten, the follow-up is deletion; either way ticket 5''s token-based cancellation of TestStatistic/DetectBlock becomes manually testable. Co-Authored-By: Claude Fable 5 --- src/LogExpert.Resources/Resources.Designer.cs | 15 ++++++++++++--- src/LogExpert.Resources/Resources.de.resx | 3 +++ src/LogExpert.Resources/Resources.resx | 3 +++ src/LogExpert.Resources/Resources.zh-CN.resx | 3 +++ .../Dialogs/LogTabWindow/LogTabWindow.cs | 8 ++++++++ .../Dialogs/LogTabWindow/LogTabWindow.designer.cs | 15 ++++++++++++--- 6 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 61748482..373572f3 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -1497,7 +1497,7 @@ public static string Lockfinder_Trace_RmEndSessionNativeMethodsRmEndSessionHandl } /// - /// Looks up a localized string similar to Error during {0} value {1}, min {2}, max {3}, visible {4}: {5}. + /// Looks up a localized string similar to Error during value {0}, min {1}, max {2}, visible {3}: {4}. /// public static string LogExpert_Common_Error_5Parameters_ErrorDuring0Value1Min2Max3Visible45 { get { @@ -1657,7 +1657,7 @@ public static string LogExpert_Common_UI_Message_ClipboardInUse { return ResourceManager.GetString("LogExpert_Common_UI_Message_ClipboardInUse", resourceCulture); } } - + /// /// Looks up a localized string similar to Deserialize. /// @@ -2545,6 +2545,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_optionToolStripMenuItem { } } + /// + /// Looks up a localized string similar to Pattern analysis.... + /// + public static string LogTabWindow_UI_ToolStripMenuItem_patternAnalysisToolStripMenuItem { + get { + return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_patternAnalysisToolStripMenuItem", resourceCulture); + } + } + /// /// Looks up a localized string similar to Reload. /// @@ -3413,7 +3422,7 @@ public static string LogWindow_UI_StatusLineError_FilterFailed { return ResourceManager.GetString("LogWindow_UI_StatusLineError_FilterFailed", resourceCulture); } } - + /// /// Looks up a localized string similar to Invalid regular expression. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index adc90283..bebe79d6 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1235,6 +1235,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Filter + + Musteranalyse... + Lesezeichen diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index b6b804cd..51da96f1 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -1183,6 +1183,9 @@ Checked tools will appear in the icon bar. All other tools are available in the Filter + + Pattern analysis... + Bookmarks diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 846b0563..4d3712d6 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -1046,6 +1046,9 @@ 筛选 + + 模式分析... + 书签 diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index d4e07c61..9cd09d8d 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -444,6 +444,7 @@ private void ApplyContextMenuResources () goToLineToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_goToLineToolStripMenuItem; searchToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_searchToolStripMenuItem; filterToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_filterToolStripMenuItem; + patternAnalysisToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_patternAnalysisToolStripMenuItem; bookmarksToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_bookmarksToolStripMenuItem; toggleBookmarkToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_toggleBookmarkToolStripMenuItem; jumpToNextToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_jumpToNextToolStripMenuItem; @@ -1058,6 +1059,7 @@ private void ChangeCurrentLogWindow (LogWindow.LogWindow newLogWindow) closeFileToolStripMenuItem.Enabled = true; searchToolStripMenuItem.Enabled = true; filterToolStripMenuItem.Enabled = true; + patternAnalysisToolStripMenuItem.Enabled = true; goToLineToolStripMenuItem.Enabled = true; ConnectToolWindows(newLogWindow); } @@ -1078,6 +1080,7 @@ private void ChangeCurrentLogWindow (LogWindow.LogWindow newLogWindow) closeFileToolStripMenuItem.Enabled = false; searchToolStripMenuItem.Enabled = false; filterToolStripMenuItem.Enabled = false; + patternAnalysisToolStripMenuItem.Enabled = false; goToLineToolStripMenuItem.Enabled = false; dragControlDateTime.Visible = false; } @@ -1934,6 +1937,11 @@ private void OnFilterToolStripMenuItemClick (object sender, EventArgs e) CurrentLogWindow?.ToggleFilterPanel(); } + private void OnPatternAnalysisToolStripMenuItemClick (object sender, EventArgs e) + { + CurrentLogWindow?.PatternStatistic(); + } + [SupportedOSPlatform("windows")] private void OnMultiFileToolStripMenuItemClick (object sender, EventArgs e) { diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs index 8f7bab2a..4ef2eb43 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs @@ -50,6 +50,7 @@ private void InitializeComponent () goToLineToolStripMenuItem = new ToolStripMenuItem(); searchToolStripMenuItem = new ToolStripMenuItem(); filterToolStripMenuItem = new ToolStripMenuItem(); + patternAnalysisToolStripMenuItem = new ToolStripMenuItem(); bookmarksToolStripMenuItem = new ToolStripMenuItem(); toggleBookmarkToolStripMenuItem = new ToolStripMenuItem(); jumpToNextToolStripMenuItem = new ToolStripMenuItem(); @@ -342,7 +343,7 @@ private void InitializeComponent () // // viewNavigateToolStripMenuItem // - viewNavigateToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { goToLineToolStripMenuItem, searchToolStripMenuItem, filterToolStripMenuItem, bookmarksToolStripMenuItem, columnFinderToolStripMenuItem, ToolStripSeparator5, encodingToolStripMenuItem, ToolStripSeparator6, timeshiftToolStripMenuItem, timeshiftToolStripTextBox, ToolStripSeparator4, copyMarkedLinesIntoNewTabToolStripMenuItem }); + viewNavigateToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { goToLineToolStripMenuItem, searchToolStripMenuItem, filterToolStripMenuItem, patternAnalysisToolStripMenuItem, bookmarksToolStripMenuItem, columnFinderToolStripMenuItem, ToolStripSeparator5, encodingToolStripMenuItem, ToolStripSeparator6, timeshiftToolStripMenuItem, timeshiftToolStripTextBox, ToolStripSeparator4, copyMarkedLinesIntoNewTabToolStripMenuItem }); viewNavigateToolStripMenuItem.Name = "viewNavigateToolStripMenuItem"; viewNavigateToolStripMenuItem.Size = new Size(96, 19); viewNavigateToolStripMenuItem.Text = "View/Navigate"; @@ -362,9 +363,16 @@ private void InitializeComponent () searchToolStripMenuItem.Size = new Size(177, 22); searchToolStripMenuItem.Text = "Search..."; searchToolStripMenuItem.Click += OnSearchToolStripMenuItemClick; - // + // + // patternAnalysisToolStripMenuItem + // + patternAnalysisToolStripMenuItem.Name = "patternAnalysisToolStripMenuItem"; + patternAnalysisToolStripMenuItem.Size = new Size(177, 22); + patternAnalysisToolStripMenuItem.Text = "Pattern analysis..."; + patternAnalysisToolStripMenuItem.Click += OnPatternAnalysisToolStripMenuItemClick; + // // filterToolStripMenuItem - // + // filterToolStripMenuItem.Image = LogExpert.Resources.Filter; filterToolStripMenuItem.Name = "filterToolStripMenuItem"; filterToolStripMenuItem.ShortcutKeys = Keys.F4; @@ -1131,6 +1139,7 @@ private void InitializeComponent () private System.Windows.Forms.ToolStripMenuItem searchToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem timeshiftToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem filterToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem patternAnalysisToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem copyMarkedLinesIntoNewTabToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem optionToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem hilightingToolStripMenuItem; From 282880e80feec0f52b4379fb3cf2e7cf18aeaf86 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 17:07:45 +0200 Subject: [PATCH 12/18] fix: actually show the Pattern Window (spike, part 2) InitPatternWindow created and configured the PatternWindow but its Show() call was commented out - the second half of how the feature went dark. The window is now shown owned by the main form, and a click while it is already open activates the existing instance instead of constructing a hidden new one. Co-Authored-By: Claude Fable 5 --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index de7afc9b..3b3b5cc3 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -5352,16 +5352,20 @@ protected internal void AddTempFileTab (string fileName, string title) private void InitPatternWindow () { - //PatternStatistic(this.patternArgs); + if (_patternWindow is { IsDisposed: false }) + { + _patternWindow.Activate(); + return; + } + _patternWindow = new PatternWindow(this); _patternWindow.SetColumnizer(CurrentColumnizer); - //this.patternWindow.SetBlockList(blockList); _patternWindow.SetFont(Preferences.Font); _patternWindow.Fuzzy = _patternArgs.Fuzzy; _patternWindow.MaxDiff = _patternArgs.MaxDiffInBlock; _patternWindow.MaxMisses = _patternArgs.MaxMisses; _patternWindow.Weight = _patternArgs.MinWeight; - //this.patternWindow.Show(); + _patternWindow.Show(FindForm()); } [SupportedOSPlatform("windows")] From 95f8f603882833b7d569659e0fd0e1a84093b150 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 17:13:06 +0200 Subject: [PATCH 13/18] Revert "fix: actually show the Pattern Window (spike, part 2)" This reverts commit 282880e80feec0f52b4379fb3cf2e7cf18aeaf86. --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 3b3b5cc3..de7afc9b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -5352,20 +5352,16 @@ protected internal void AddTempFileTab (string fileName, string title) private void InitPatternWindow () { - if (_patternWindow is { IsDisposed: false }) - { - _patternWindow.Activate(); - return; - } - + //PatternStatistic(this.patternArgs); _patternWindow = new PatternWindow(this); _patternWindow.SetColumnizer(CurrentColumnizer); + //this.patternWindow.SetBlockList(blockList); _patternWindow.SetFont(Preferences.Font); _patternWindow.Fuzzy = _patternArgs.Fuzzy; _patternWindow.MaxDiff = _patternArgs.MaxDiffInBlock; _patternWindow.MaxMisses = _patternArgs.MaxMisses; _patternWindow.Weight = _patternArgs.MinWeight; - _patternWindow.Show(FindForm()); + //this.patternWindow.Show(); } [SupportedOSPlatform("windows")] From 5a42957a25e755429f7f3d2ecf7b26ad1a4d8ce8 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 17:13:06 +0200 Subject: [PATCH 14/18] Revert "feat: re-wire the orphaned Pattern analysis menu item (spike)" This reverts commit 0118dbddad739739d43e76524cd25c7bf9be7653. --- src/LogExpert.Resources/Resources.Designer.cs | 15 +++------------ src/LogExpert.Resources/Resources.de.resx | 3 --- src/LogExpert.Resources/Resources.resx | 3 --- src/LogExpert.Resources/Resources.zh-CN.resx | 3 --- .../Dialogs/LogTabWindow/LogTabWindow.cs | 8 -------- .../Dialogs/LogTabWindow/LogTabWindow.designer.cs | 15 +++------------ 6 files changed, 6 insertions(+), 41 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 373572f3..61748482 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -1497,7 +1497,7 @@ public static string Lockfinder_Trace_RmEndSessionNativeMethodsRmEndSessionHandl } /// - /// Looks up a localized string similar to Error during value {0}, min {1}, max {2}, visible {3}: {4}. + /// Looks up a localized string similar to Error during {0} value {1}, min {2}, max {3}, visible {4}: {5}. /// public static string LogExpert_Common_Error_5Parameters_ErrorDuring0Value1Min2Max3Visible45 { get { @@ -1657,7 +1657,7 @@ public static string LogExpert_Common_UI_Message_ClipboardInUse { return ResourceManager.GetString("LogExpert_Common_UI_Message_ClipboardInUse", resourceCulture); } } - + /// /// Looks up a localized string similar to Deserialize. /// @@ -2545,15 +2545,6 @@ public static string LogTabWindow_UI_ToolStripMenuItem_optionToolStripMenuItem { } } - /// - /// Looks up a localized string similar to Pattern analysis.... - /// - public static string LogTabWindow_UI_ToolStripMenuItem_patternAnalysisToolStripMenuItem { - get { - return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_patternAnalysisToolStripMenuItem", resourceCulture); - } - } - /// /// Looks up a localized string similar to Reload. /// @@ -3422,7 +3413,7 @@ public static string LogWindow_UI_StatusLineError_FilterFailed { return ResourceManager.GetString("LogWindow_UI_StatusLineError_FilterFailed", resourceCulture); } } - + /// /// Looks up a localized string similar to Invalid regular expression. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index bebe79d6..adc90283 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1235,9 +1235,6 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Filter - - Musteranalyse... - Lesezeichen diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 51da96f1..b6b804cd 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -1183,9 +1183,6 @@ Checked tools will appear in the icon bar. All other tools are available in the Filter - - Pattern analysis... - Bookmarks diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 4d3712d6..846b0563 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -1046,9 +1046,6 @@ 筛选 - - 模式分析... - 书签 diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 9cd09d8d..d4e07c61 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -444,7 +444,6 @@ private void ApplyContextMenuResources () goToLineToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_goToLineToolStripMenuItem; searchToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_searchToolStripMenuItem; filterToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_filterToolStripMenuItem; - patternAnalysisToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_patternAnalysisToolStripMenuItem; bookmarksToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_bookmarksToolStripMenuItem; toggleBookmarkToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_toggleBookmarkToolStripMenuItem; jumpToNextToolStripMenuItem.Text = Resources.LogTabWindow_UI_ToolStripMenuItem_jumpToNextToolStripMenuItem; @@ -1059,7 +1058,6 @@ private void ChangeCurrentLogWindow (LogWindow.LogWindow newLogWindow) closeFileToolStripMenuItem.Enabled = true; searchToolStripMenuItem.Enabled = true; filterToolStripMenuItem.Enabled = true; - patternAnalysisToolStripMenuItem.Enabled = true; goToLineToolStripMenuItem.Enabled = true; ConnectToolWindows(newLogWindow); } @@ -1080,7 +1078,6 @@ private void ChangeCurrentLogWindow (LogWindow.LogWindow newLogWindow) closeFileToolStripMenuItem.Enabled = false; searchToolStripMenuItem.Enabled = false; filterToolStripMenuItem.Enabled = false; - patternAnalysisToolStripMenuItem.Enabled = false; goToLineToolStripMenuItem.Enabled = false; dragControlDateTime.Visible = false; } @@ -1937,11 +1934,6 @@ private void OnFilterToolStripMenuItemClick (object sender, EventArgs e) CurrentLogWindow?.ToggleFilterPanel(); } - private void OnPatternAnalysisToolStripMenuItemClick (object sender, EventArgs e) - { - CurrentLogWindow?.PatternStatistic(); - } - [SupportedOSPlatform("windows")] private void OnMultiFileToolStripMenuItemClick (object sender, EventArgs e) { diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs index 4ef2eb43..8f7bab2a 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs @@ -50,7 +50,6 @@ private void InitializeComponent () goToLineToolStripMenuItem = new ToolStripMenuItem(); searchToolStripMenuItem = new ToolStripMenuItem(); filterToolStripMenuItem = new ToolStripMenuItem(); - patternAnalysisToolStripMenuItem = new ToolStripMenuItem(); bookmarksToolStripMenuItem = new ToolStripMenuItem(); toggleBookmarkToolStripMenuItem = new ToolStripMenuItem(); jumpToNextToolStripMenuItem = new ToolStripMenuItem(); @@ -343,7 +342,7 @@ private void InitializeComponent () // // viewNavigateToolStripMenuItem // - viewNavigateToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { goToLineToolStripMenuItem, searchToolStripMenuItem, filterToolStripMenuItem, patternAnalysisToolStripMenuItem, bookmarksToolStripMenuItem, columnFinderToolStripMenuItem, ToolStripSeparator5, encodingToolStripMenuItem, ToolStripSeparator6, timeshiftToolStripMenuItem, timeshiftToolStripTextBox, ToolStripSeparator4, copyMarkedLinesIntoNewTabToolStripMenuItem }); + viewNavigateToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { goToLineToolStripMenuItem, searchToolStripMenuItem, filterToolStripMenuItem, bookmarksToolStripMenuItem, columnFinderToolStripMenuItem, ToolStripSeparator5, encodingToolStripMenuItem, ToolStripSeparator6, timeshiftToolStripMenuItem, timeshiftToolStripTextBox, ToolStripSeparator4, copyMarkedLinesIntoNewTabToolStripMenuItem }); viewNavigateToolStripMenuItem.Name = "viewNavigateToolStripMenuItem"; viewNavigateToolStripMenuItem.Size = new Size(96, 19); viewNavigateToolStripMenuItem.Text = "View/Navigate"; @@ -363,16 +362,9 @@ private void InitializeComponent () searchToolStripMenuItem.Size = new Size(177, 22); searchToolStripMenuItem.Text = "Search..."; searchToolStripMenuItem.Click += OnSearchToolStripMenuItemClick; - // - // patternAnalysisToolStripMenuItem - // - patternAnalysisToolStripMenuItem.Name = "patternAnalysisToolStripMenuItem"; - patternAnalysisToolStripMenuItem.Size = new Size(177, 22); - patternAnalysisToolStripMenuItem.Text = "Pattern analysis..."; - patternAnalysisToolStripMenuItem.Click += OnPatternAnalysisToolStripMenuItemClick; - // + // // filterToolStripMenuItem - // + // filterToolStripMenuItem.Image = LogExpert.Resources.Filter; filterToolStripMenuItem.Name = "filterToolStripMenuItem"; filterToolStripMenuItem.ShortcutKeys = Keys.F4; @@ -1139,7 +1131,6 @@ private void InitializeComponent () private System.Windows.Forms.ToolStripMenuItem searchToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem timeshiftToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem filterToolStripMenuItem; - private System.Windows.Forms.ToolStripMenuItem patternAnalysisToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem copyMarkedLinesIntoNewTabToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem optionToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem hilightingToolStripMenuItem; From 4974ad459a0b37f0a87b483630b32cbe5cfb7f58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 15:20:55 +0000 Subject: [PATCH 15/18] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 7c958309..52dc015b 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-09 21:05:43 UTC + /// Generated: 2026-07-11 15:20:54 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "81F2CD2C2F2A67C577F3DA2BB1C52884249332B4A2741FC921C18D5AF9F68B30", + ["AutoColumnizer.dll"] = "EC0BF0D59800D7785131C93C74C5575ED4D24297911B8EC6D5A8C6624C585308", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726", - ["CsvColumnizer.dll (x86)"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726", - ["DefaultPlugins.dll"] = "4B5206DB37D091B48037DE2C03A573BD3678772F412B128C1E57614E1EDC9065", - ["FlashIconHighlighter.dll"] = "D12A6517CC0402A8E3A580CFE50F2AB81C15F4019159B784416EC7F8FE73FBA8", - ["GlassfishColumnizer.dll"] = "2BA6B760A044FEE75EDC3BC478605E9E0AA9CE4F52C9F435EBA38A9EDD45A109", - ["JsonColumnizer.dll"] = "FD407BC814B287588F4DB0FE90BAA104BE5050D7F6097D183F9E0BE906EC8C5E", - ["JsonCompactColumnizer.dll"] = "B80B2AD30181E94C08286835CE01C607FA7FD66722EE0C5DB2A7C16AB357A09F", - ["Log4jXmlColumnizer.dll"] = "942FCD055F0C9BC2D141D9D2B407BAB14F89719F8AF35F50326F77FC89C74C42", - ["LogExpert.Resources.dll"] = "85E2154489ADDC1DC52B67EA0784519AB6427BECB4AD1B8A2BE29D796D3C41C8", + ["CsvColumnizer.dll"] = "ADEB4E67F8B001DC7A4CA67CC80A553678DD9E37E81BDEB54C9CF8217F0AD40B", + ["CsvColumnizer.dll (x86)"] = "ADEB4E67F8B001DC7A4CA67CC80A553678DD9E37E81BDEB54C9CF8217F0AD40B", + ["DefaultPlugins.dll"] = "87BE0E74197498E8C29EDC832A90EB04657F090C37C8516046496163A51A6B9A", + ["FlashIconHighlighter.dll"] = "8C81CBCB3AB163FBA4A9170282ED62FA258D53E99BC11D00B051189A29E913DA", + ["GlassfishColumnizer.dll"] = "248C6993A57CE0E9AD3AC2E8FEE29FD49F87D7D380A93E8FAF724665FC4D2D3E", + ["JsonColumnizer.dll"] = "5D7A74E640097B5761A65300C044947F20BACA525FA04193C7C12845E1FBA874", + ["JsonCompactColumnizer.dll"] = "FD7934E7527BC264F2D2431AEBB0AC6F17319615894B4A67105424EDC6538237", + ["Log4jXmlColumnizer.dll"] = "C2D2D5846416847BE9591AA46F6BBF6171DEFD12235CB36CAA141D7218A2944D", + ["LogExpert.Resources.dll"] = "C34465B763AA6D9DF08F3E65AF47A14AE3D0F0E8027EFB9A1DB9B538459B36BD", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "ECDDA17BBC01DF90E82065BAE66223568C4F0B44E3F49F595A6178CC4D5A5F5B", - ["SftpFileSystem.dll"] = "EA5235D8C1787798A349D38BEE086035AB79AA25DBD1C7C1713FA36379614690", - ["SftpFileSystem.dll (x86)"] = "398428B6D28C3FA4494BDD67156CAC72240B0E74D963F24B8B77295CA11C7E93", - ["SftpFileSystem.Resources.dll"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890", - ["SftpFileSystem.Resources.dll (x86)"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890", + ["RegexColumnizer.dll"] = "1D9E3886C82953F3DBDEED0B993C3189992EB09A0BE04D8755AFD1DABD2FFF7F", + ["SftpFileSystem.dll"] = "58AD4D2062927345CEC85F5AD71709C73F59C5B3CFED65B3A4C492A122E7E559", + ["SftpFileSystem.dll (x86)"] = "CDBBA5EFF60F662F22D24CB77F9560CC1A13359F3949BC7D3A93BD510EABDB99", + ["SftpFileSystem.Resources.dll"] = "301582E9F16B5C54D31C6E0A73428D665E565AEE062EF2D1AB9A89438B5DF1E0", + ["SftpFileSystem.Resources.dll (x86)"] = "301582E9F16B5C54D31C6E0A73428D665E565AEE062EF2D1AB9A89438B5DF1E0", }; } From 4694c23cc4b7684d61f728d95e288ab7e3059bb9 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 17:23:21 +0200 Subject: [PATCH 16/18] refactor: drop the tautological wasFollow check in the stop-tail dispatch wasFollow was read from _guiStateArgs.FollowTail immediately after the enclosing condition required that same property to be true, so it was always true and the inner condition reduced to firstStopTail. Inherited verbatim from both pre-dedupe loop bodies; now that the dispatch exists once, the dead variable goes. firstStopTail stays - it enforces the scroll-once-per-batch rule if follow-tail is re-enabled mid-batch. Co-Authored-By: Claude Fable 5 --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index de7afc9b..7ef5a109 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -3222,9 +3222,11 @@ private void CheckFilterAndHighlight (LogEventArgs e) if (stopTail && _guiStateArgs.FollowTail) { - var wasFollow = _guiStateArgs.FollowTail; FollowTailChanged(false, true); - if (firstStopTail && wasFollow) + + // Scroll to the triggering line once per batch, even if follow-tail gets + // re-enabled while the batch is still being processed. + if (firstStopTail) { var capturedLineNum = i; _ = BeginInvoke(() => SelectAndEnsureVisible(capturedLineNum, false)); From fb2de8d575e290d4b701ddaa34bd6b61f22f5370 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 17:24:14 +0200 Subject: [PATCH 17/18] chore: normalize Resources.Designer.cs to generator output Co-Authored-By: Claude Fable 5 --- src/LogExpert.Resources/Resources.Designer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 61748482..9ea090e3 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -1497,7 +1497,7 @@ public static string Lockfinder_Trace_RmEndSessionNativeMethodsRmEndSessionHandl } /// - /// Looks up a localized string similar to Error during {0} value {1}, min {2}, max {3}, visible {4}: {5}. + /// Looks up a localized string similar to Error during value {0}, min {1}, max {2}, visible {3}: {4}. /// public static string LogExpert_Common_Error_5Parameters_ErrorDuring0Value1Min2Max3Visible45 { get { @@ -1657,7 +1657,7 @@ public static string LogExpert_Common_UI_Message_ClipboardInUse { return ResourceManager.GetString("LogExpert_Common_UI_Message_ClipboardInUse", resourceCulture); } } - + /// /// Looks up a localized string similar to Deserialize. /// @@ -3413,7 +3413,7 @@ public static string LogWindow_UI_StatusLineError_FilterFailed { return ResourceManager.GetString("LogWindow_UI_StatusLineError_FilterFailed", resourceCulture); } } - + /// /// Looks up a localized string similar to Invalid regular expression. /// From af71efdf906b26866c3cce99d03545adc6141320 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 15:26:06 +0000 Subject: [PATCH 18/18] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 52dc015b..f39f98ad 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-11 15:20:54 UTC + /// Generated: 2026-07-11 15:26:05 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "EC0BF0D59800D7785131C93C74C5575ED4D24297911B8EC6D5A8C6624C585308", + ["AutoColumnizer.dll"] = "1A1D4B6181F93955C71CB0591779628EDCCBCD4D46891DD2FF36828086C883C9", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "ADEB4E67F8B001DC7A4CA67CC80A553678DD9E37E81BDEB54C9CF8217F0AD40B", - ["CsvColumnizer.dll (x86)"] = "ADEB4E67F8B001DC7A4CA67CC80A553678DD9E37E81BDEB54C9CF8217F0AD40B", - ["DefaultPlugins.dll"] = "87BE0E74197498E8C29EDC832A90EB04657F090C37C8516046496163A51A6B9A", - ["FlashIconHighlighter.dll"] = "8C81CBCB3AB163FBA4A9170282ED62FA258D53E99BC11D00B051189A29E913DA", - ["GlassfishColumnizer.dll"] = "248C6993A57CE0E9AD3AC2E8FEE29FD49F87D7D380A93E8FAF724665FC4D2D3E", - ["JsonColumnizer.dll"] = "5D7A74E640097B5761A65300C044947F20BACA525FA04193C7C12845E1FBA874", - ["JsonCompactColumnizer.dll"] = "FD7934E7527BC264F2D2431AEBB0AC6F17319615894B4A67105424EDC6538237", - ["Log4jXmlColumnizer.dll"] = "C2D2D5846416847BE9591AA46F6BBF6171DEFD12235CB36CAA141D7218A2944D", - ["LogExpert.Resources.dll"] = "C34465B763AA6D9DF08F3E65AF47A14AE3D0F0E8027EFB9A1DB9B538459B36BD", + ["CsvColumnizer.dll"] = "6A2FD7BA67EE1DF94CF11D326E6E66EF341B3E4025B1EDA0ED599F4A256817E4", + ["CsvColumnizer.dll (x86)"] = "6A2FD7BA67EE1DF94CF11D326E6E66EF341B3E4025B1EDA0ED599F4A256817E4", + ["DefaultPlugins.dll"] = "8CFAC3764C109FFB1292B3DF62BABDFFDCF9025DAD09A93D9EDA60B322624237", + ["FlashIconHighlighter.dll"] = "A1F84BF9CB7A061719633CA879B65A7EB3D6A7E48C1C0B2BCEFEF2A461347941", + ["GlassfishColumnizer.dll"] = "1A70A1C2498170F21674B5891A93631AC9DDCE3D1D8CC547E3755DA91F8E897D", + ["JsonColumnizer.dll"] = "7FA4DABA1E29841B0DC254BA9047E96C70E10A086F063F6D1089E94D567CE110", + ["JsonCompactColumnizer.dll"] = "52F762DFBDA5771FEFE65E8A4A326F23D5FB33F69933E33A4FB9A236953B38B7", + ["Log4jXmlColumnizer.dll"] = "1A50FF0ED6C60D934B05058987F8A2C19300449E95AD2324B42432FF20339FB6", + ["LogExpert.Resources.dll"] = "EF96F863858116E94F7FF4C635CEE3AF2A43FCACC2EEE89E081E2CC5C739874A", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "1D9E3886C82953F3DBDEED0B993C3189992EB09A0BE04D8755AFD1DABD2FFF7F", - ["SftpFileSystem.dll"] = "58AD4D2062927345CEC85F5AD71709C73F59C5B3CFED65B3A4C492A122E7E559", - ["SftpFileSystem.dll (x86)"] = "CDBBA5EFF60F662F22D24CB77F9560CC1A13359F3949BC7D3A93BD510EABDB99", - ["SftpFileSystem.Resources.dll"] = "301582E9F16B5C54D31C6E0A73428D665E565AEE062EF2D1AB9A89438B5DF1E0", - ["SftpFileSystem.Resources.dll (x86)"] = "301582E9F16B5C54D31C6E0A73428D665E565AEE062EF2D1AB9A89438B5DF1E0", + ["RegexColumnizer.dll"] = "C6D70682B1708E8EF4479D105CBC6F3A92E9C5A6FB7E1C5D68C4B6ECB5CDD75F", + ["SftpFileSystem.dll"] = "E2551A64D602C59F069B6600FABDA8ACB49E9D6B9697C82DE8166BAF0E3836B9", + ["SftpFileSystem.dll (x86)"] = "37F2E09661274513A2B2418BD0CFE6012C8704F74B041A118B125CCB90CC4E14", + ["SftpFileSystem.Resources.dll"] = "A8934B9231E463B7A9ED62589523FF045489721AF1A4E6F941241CA1D285FC7F", + ["SftpFileSystem.Resources.dll (x86)"] = "A8934B9231E463B7A9ED62589523FF045489721AF1A4E6F941241CA1D285FC7F", }; }