From 774fa518c08dcae834b4ae07e2ed4fffe9b541c8 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 18:51:00 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20add=20TimestampLocator=20?= =?UTF-8?q?=E2=80=94=20pure=20timestamp=20lookup=20over=20ILogfileReader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 1 of the Timestamp Locator extraction (candidate 6, docs/improve/logwindow-architecture-review.html). Ports LogWindow's four timestamp-lookup methods (binary search + forward/backward scan) into a Core module tested against a fake ITimestampSource — no WinForms type involved. Lands unused; LogWindow is migrated in the next commit. FindForward returns a tuple instead of using a ref int (closes the referenced-int shape of issue #404 for this seam), and positions the Columnizer callback on every line it inspects in both directions — the original forward scan never did, unlike its backward counterpart. 32 tests cover both scan directions, the binary search, the negated near-miss return on a search miss (including the -0/hit-at-line-zero ambiguity, preserved rather than "fixed" since it's inherent to the ported algorithm), and cancellation. --- .../Classes/Timestamp/ITimestampSource.cs | 41 ++ .../Classes/Timestamp/TimestampLocator.cs | 217 +++++++ .../IPositionedColumnizerCallback.cs | 23 + .../Timestamp/TimestampLocatorTests.cs | 606 ++++++++++++++++++ 4 files changed, 887 insertions(+) create mode 100644 src/LogExpert.Core/Classes/Timestamp/ITimestampSource.cs create mode 100644 src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs create mode 100644 src/LogExpert.Core/Interfaces/IPositionedColumnizerCallback.cs create mode 100644 src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs diff --git a/src/LogExpert.Core/Classes/Timestamp/ITimestampSource.cs b/src/LogExpert.Core/Classes/Timestamp/ITimestampSource.cs new file mode 100644 index 00000000..4f5b571a --- /dev/null +++ b/src/LogExpert.Core/Classes/Timestamp/ITimestampSource.cs @@ -0,0 +1,41 @@ +using System.Threading; + +using ColumnizerLib; + +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Timestamp; + +/// +/// The narrow view of a Log Window that needs: a Logfile Reader, +/// the active Columnizer, a callback to position, and the lock that guards Columnizer swaps. +/// +/// +/// Every member is a property rather than a value captured at construction, deliberately. +/// A Log Window replaces its Logfile Reader on load, reload and rollover, and replaces its +/// Columnizer whenever the user picks a different one; a locator that had captured either +/// would go stale. Consumers such as the Time Spread calculator outlive both events. +/// +public interface ITimestampSource +{ + /// + /// The Logfile Reader currently backing the window. Read on every access — never cached. + /// + ILogfileReader Reader { get; } + + /// + /// The Columnizer currently selected for the window. Read under . + /// + ILogLineMemoryColumnizer Columnizer { get; } + + /// + /// The callback handed to the Columnizer. The locator positions it on each line it asks about. + /// + IPositionedColumnizerCallback Callback { get; } + + /// + /// Guards against being swapped mid-lookup. Owned by the window — + /// the same lock its Columnizer setter takes — and merely borrowed by the locator. + /// + Lock ColumnizerLock { get; } +} diff --git a/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs b/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs new file mode 100644 index 00000000..bbbc13e9 --- /dev/null +++ b/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs @@ -0,0 +1,217 @@ +namespace LogExpert.Core.Classes.Timestamp; + +/// +/// Timestamp lookup over a Logfile Reader and the active Columnizer: what time is the line at N, +/// and which line carries time T. +/// +public sealed class TimestampLocator (ITimestampSource source) +{ + /// + /// Gets the timestamp for the line at or before . If that line has + /// no timestamp, the previous line is checked, and so on, until one is found. + /// + /// The line to start scanning backward from. + /// The number of lines currently available (ILogfileReader.LineCount). + /// If true, the returned timestamp has its millisecond component zeroed. + /// Checked once per line; a cancelled token stops the scan and returns MinValue. + /// The timestamp found, or if none was, and the line + /// number it was found on (unchanged from if scanning never moved). + public (DateTime timestamp, int lineNumber) FindBackward (int lineNum, int lineCount, bool roundToSeconds, CancellationToken token = default) + { + lock (source.ColumnizerLock) + { + if (!source.Columnizer.IsTimeshiftImplemented()) + { + return (DateTime.MinValue, lineNum); + } + + var timestamp = DateTime.MinValue; + var lookedBack = false; + + if (lineNum >= 0 && lineNum < lineCount) + { + while (timestamp.CompareTo(DateTime.MinValue) == 0 && lineNum >= 0) + { + if (token.IsCancellationRequested) + { + return (DateTime.MinValue, lineNum); + } + + lookedBack = true; + var logLine = source.Reader.GetLogLineMemory(lineNum); + if (logLine == null) + { + return (DateTime.MinValue, lineNum); + } + + source.Callback.SetLineNum(lineNum); + timestamp = source.Columnizer.GetTimestamp(source.Callback, logLine); + if (roundToSeconds) + { + timestamp = timestamp.Subtract(TimeSpan.FromMilliseconds(timestamp.Millisecond)); + } + + lineNum--; + } + } + + if (lookedBack) + { + lineNum++; + } + + return (timestamp, lineNum); + } + } + + /// + /// Gets the timestamp for the line at or after . If that line has no + /// timestamp, the next line is checked, and so on, until one is found. + /// + /// The line to start scanning forward from. + /// The number of lines currently available (ILogfileReader.LineCount). + /// If true, the returned timestamp has its millisecond component zeroed. + /// The timestamp found, or if none was, and the line + /// number it was found on (unchanged from if scanning never moved). + public (DateTime timestamp, int lineNumber) FindForward (int lineNum, int lineCount, bool roundToSeconds) + { + lock (source.ColumnizerLock) + { + if (!source.Columnizer.IsTimeshiftImplemented()) + { + return (DateTime.MinValue, lineNum); + } + + var timestamp = DateTime.MinValue; + var lookedForward = false; + + if (lineNum >= 0 && lineNum < lineCount) + { + while (timestamp.CompareTo(DateTime.MinValue) == 0 && lineNum < lineCount) + { + lookedForward = true; + var logLine = source.Reader.GetLogLineMemory(lineNum); + if (logLine == null) + { + timestamp = DateTime.MinValue; + break; + } + + source.Callback.SetLineNum(lineNum); + timestamp = source.Columnizer.GetTimestamp(source.Callback, logLine); + if (roundToSeconds) + { + timestamp = timestamp.Subtract(TimeSpan.FromMilliseconds(timestamp.Millisecond)); + } + + lineNum++; + } + } + + if (lookedForward) + { + lineNum--; + } + + return (timestamp, lineNum); + } + } + + /// + /// Finds the line carrying via binary search, then walks backward + /// to the first line of a run sharing that exact timestamp. + /// + /// The timestamp to search for. + /// Line to start the binary search from. + /// The number of lines currently available (ILogfileReader.LineCount). + /// If true, timestamps are compared with their millisecond component zeroed. + /// Checked by the underlying scans; a cancelled token unwinds the search early. + /// + /// The line number of the first line carrying . If no line carries + /// it exactly, returns the negated line number nearest the search — callers that care + /// about a miss must flip the sign back themselves; this mirrors the ported behaviour of the + /// original FindTimestampLine. + /// + public int FindLine (DateTime timestamp, int fromLine, int lineCount, bool roundToSeconds, CancellationToken token = default) + { + var foundLine = FindLineInternal(fromLine, 0, lineCount - 1, timestamp, lineCount, roundToSeconds, token); + + if (foundLine < 0) + { + return foundLine; + } + + // Walk backward to the first line of the run sharing this exact timestamp. + var (foundTimestamp, walkedTo) = FindBackward(foundLine, lineCount, roundToSeconds, token); + foundLine = walkedTo; + while (foundTimestamp.CompareTo(timestamp) == 0 && foundLine >= 0) + { + foundLine--; + (foundTimestamp, walkedTo) = FindBackward(foundLine, lineCount, roundToSeconds, token); + foundLine = walkedTo; + } + + if (foundLine < 0) + { + return 0; + } + + foundLine++; + (_, foundLine) = FindForward(foundLine, lineCount, roundToSeconds); // step to the next valid timestamp + return foundLine; + } + + /// + /// The raw binary-search step, without 's walk-back to the first line of a + /// duplicate-timestamp run. Exposed for TimeSpreadCalculator, which does its own + /// (cheaper) handling of a miss and does not need the run-collapsing behaviour. + /// + /// The matching line, or the near-miss line negated — same convention as . + public int FindNearestLine (DateTime timestamp, int fromLine, int rangeStart, int rangeEnd, int lineCount, bool roundToSeconds, CancellationToken token = default) + { + return FindLineInternal(fromLine, rangeStart, rangeEnd, timestamp, lineCount, roundToSeconds, token); + } + + private int FindLineInternal (int lineNum, int rangeStart, int rangeEnd, DateTime timestamp, int lineCount, bool roundToSeconds, CancellationToken token) + { + var (currentTimestamp, foundLine) = FindBackward(lineNum, lineCount, roundToSeconds, token); + if (currentTimestamp.CompareTo(timestamp) == 0) + { + return foundLine; + } + + if (timestamp < currentTimestamp) + { + rangeEnd = lineNum; + } + else + { + rangeStart = lineNum; + } + + if (rangeEnd - rangeStart <= 0) + { + return -lineNum; + } + + lineNum = ((rangeEnd - rangeStart) / 2) + rangeStart; + + // Prevent an endless loop when the range can no longer be halved. + if (rangeEnd - rangeStart < 2) + { + (currentTimestamp, rangeStart) = FindBackward(rangeStart, lineCount, roundToSeconds, token); + if (currentTimestamp.CompareTo(timestamp) == 0) + { + return rangeStart; + } + + (currentTimestamp, rangeEnd) = FindBackward(rangeEnd, lineCount, roundToSeconds, token); + + return currentTimestamp.CompareTo(timestamp) == 0 + ? rangeEnd + : -lineNum; + } + + return FindLineInternal(lineNum, rangeStart, rangeEnd, timestamp, lineCount, roundToSeconds, token); + } +} diff --git a/src/LogExpert.Core/Interfaces/IPositionedColumnizerCallback.cs b/src/LogExpert.Core/Interfaces/IPositionedColumnizerCallback.cs new file mode 100644 index 00000000..e32b682c --- /dev/null +++ b/src/LogExpert.Core/Interfaces/IPositionedColumnizerCallback.cs @@ -0,0 +1,23 @@ +using ColumnizerLib; + +namespace LogExpert.Core.Interfaces; + +/// +/// A Columnizer callback whose current line number can be moved by the caller. +/// +/// +/// is read-only on the plugin-facing +/// interface: a Columnizer may ask which line it is working on, but may not move it. The host, +/// however, must position the callback before every call it makes into a Columnizer, because +/// resolves through the current line +/// number in Multi-File Mode. +/// +public interface IPositionedColumnizerCallback : ILogLineMemoryColumnizerCallback +{ + /// + /// Moves the callback to so that subsequent Columnizer calls + /// resolve their context against that line. + /// + /// Zero-based line number. + void SetLineNum (int lineNum); +} diff --git a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs new file mode 100644 index 00000000..3faf6be6 --- /dev/null +++ b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs @@ -0,0 +1,606 @@ +using System.Globalization; +using System.Linq; +using System.Threading; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Timestamp; +using LogExpert.Core.Interfaces; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Timestamp; + +/// +/// Tests for the Timestamp Locator seam. Every test runs against a fake +/// over an in-memory line list — no WinForms type is instantiated, +/// which is the whole point of the extraction. +/// +[TestFixture] +public class TimestampLocatorTests +{ + /// A line with no parsable timestamp. The Columnizer contract for that is MinValue. + private const string NoTime = ""; + + [Test] + public void FindBackward_LineHasATimestamp_ReturnsItAndTheSameLine () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); + + var (timestamp, lineNumber) = locator.FindBackward(1, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:01"))); + Assert.That(lineNumber, Is.EqualTo(1)); + }); + } + + [Test] + public void FindBackward_LineHasNoTimestamp_ScansBackToTheNearestLineThatDoes () + { + var locator = LocatorOver("2026-01-01 10:00:00", NoTime, NoTime); + + var (timestamp, lineNumber) = locator.FindBackward(2, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:00"))); + Assert.That(lineNumber, Is.EqualTo(0)); + }); + } + + [Test] + public void FindBackward_NoLineInRangeHasATimestamp_ReturnsMinValue () + { + var locator = LocatorOver(NoTime, NoTime, NoTime); + + var (timestamp, _) = locator.FindBackward(2, 3, roundToSeconds: false); + + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + } + + [Test] + public void FindBackward_LineNumberAboveLineCount_ReturnsMinValueWithoutScanning () + { + var locator = LocatorOver("2026-01-01 10:00:00"); + + var (timestamp, lineNumber) = locator.FindBackward(5, 1, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(5), "line number is returned unchanged when scanning never starts"); + }); + } + + [Test] + public void FindBackward_NegativeLineNumber_ReturnsMinValueWithoutScanning () + { + var locator = LocatorOver("2026-01-01 10:00:00"); + + var (timestamp, lineNumber) = locator.FindBackward(-1, 1, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(-1)); + }); + } + + [Test] + public void FindBackward_LineCountIsZero_ReturnsMinValueWithoutScanning () + { + var locator = LocatorOver(); + + var (timestamp, lineNumber) = locator.FindBackward(0, 0, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(0)); + }); + } + + [Test] + public void FindBackward_ReaderReturnsNullLine_ReturnsMinValueAndStops () + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(3); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())).Returns((ILogLineMemory)null!); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); + + var (timestamp, lineNumber) = locator.FindBackward(2, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(2)); + }); + } + + [Test] + public void FindBackward_ColumnizerDoesNotImplementTimeshift_ReturnsMinValueWithoutTouchingTheReader () + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(3); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(false); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); + + var (timestamp, lineNumber) = locator.FindBackward(1, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(1)); + }); + readerMock.Verify(r => r.GetLogLineMemory(It.IsAny()), Times.Never); + } + + [Test] + public void FindBackward_RoundToSeconds_ZeroesTheMillisecondComponent () + { + var locator = LocatorOver("2026-01-01 10:00:00.750"); + + var (timestamp, _) = locator.FindBackward(0, 1, roundToSeconds: true); + + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:00.000"))); + } + + [Test] + public void FindBackward_CancelledToken_StopsScanningAndReturnsMinValue () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var (timestamp, lineNumber) = locator.FindBackward(1, 2, roundToSeconds: false, cts.Token); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(1)); + }); + } + + [Test] + public void FindBackward_PositionsTheCallbackOnEachLineItInspects () + { + var callback = new RecordingCallback(); + var sourceMock = new Mock(); + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(3); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNum) => LineOf(lineNum == 0 ? "2026-01-01 10:00:00" : NoTime)); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + _ = columnizerMock.Setup(c => c.GetTimestamp(It.IsAny(), It.IsAny())) + .Returns((ILogLineMemoryColumnizerCallback _, ILogLineMemory logLine) => ParseOrMinValue(logLine)); + + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(callback); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); + + _ = locator.FindBackward(2, 3, roundToSeconds: false); + + Assert.That(callback.PositionedAt, Is.EqualTo(new[] { 2, 1, 0 })); + } + + [Test] + public void FindForward_LineHasATimestamp_ReturnsItAndTheSameLine () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); + + var (timestamp, lineNumber) = locator.FindForward(1, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:01"))); + Assert.That(lineNumber, Is.EqualTo(1)); + }); + } + + [Test] + public void FindForward_LineHasNoTimestamp_ScansForwardToTheNearestLineThatDoes () + { + var locator = LocatorOver(NoTime, NoTime, "2026-01-01 10:00:02"); + + var (timestamp, lineNumber) = locator.FindForward(0, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:02"))); + Assert.That(lineNumber, Is.EqualTo(2)); + }); + } + + [Test] + public void FindForward_NoLineInRangeHasATimestamp_ReturnsMinValue () + { + var locator = LocatorOver(NoTime, NoTime, NoTime); + + var (timestamp, _) = locator.FindForward(0, 3, roundToSeconds: false); + + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + } + + [Test] + public void FindForward_LineNumberAtOrAboveLineCount_ReturnsMinValueWithoutScanning () + { + var locator = LocatorOver("2026-01-01 10:00:00"); + + var (timestamp, lineNumber) = locator.FindForward(1, 1, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(1)); + }); + } + + [Test] + public void FindForward_RoundToSeconds_ZeroesTheMillisecondComponent () + { + var locator = LocatorOver("2026-01-01 10:00:00.750"); + + var (timestamp, _) = locator.FindForward(0, 1, roundToSeconds: true); + + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:00.000"))); + } + + [Test] + public void FindForward_ReaderReturnsNullLine_ReturnsMinValueAndStops () + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(3); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())).Returns((ILogLineMemory)null!); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); + + var (timestamp, lineNumber) = locator.FindForward(0, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + // Ported quirk, preserved verbatim: unlike FindBackward's early `return` on a null line, + // the forward scan `break`s, so the post-loop "scanning moved" decrement still fires — + // one below the starting line, not the starting line itself. + Assert.That(lineNumber, Is.EqualTo(-1)); + }); + } + + [Test] + public void FindForward_ColumnizerDoesNotImplementTimeshift_ReturnsMinValueWithoutTouchingTheReader () + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(3); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(false); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); + + var (timestamp, lineNumber) = locator.FindForward(0, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(0)); + }); + readerMock.Verify(r => r.GetLogLineMemory(It.IsAny()), Times.Never); + } + + /// + /// Regression seam for the fix made during the Timestamp Locator extraction: the original + /// GetTimestampForLineForward never positioned the Columnizer callback before calling + /// into the Columnizer, unlike its backward counterpart. No in-tree Columnizer reads + /// callback.LineNum on this path, so it was latent rather than a live bug — but any + /// third-party Columnizer, or Multi-File Mode's GetFileName() resolution, depends on it. + /// + [Test] + public void FindForward_PositionsTheCallbackOnEachLineItInspects () + { + var callback = new RecordingCallback(); + var sourceMock = new Mock(); + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(3); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNum) => LineOf(lineNum == 2 ? "2026-01-01 10:00:00" : NoTime)); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + _ = columnizerMock.Setup(c => c.GetTimestamp(It.IsAny(), It.IsAny())) + .Returns((ILogLineMemoryColumnizerCallback _, ILogLineMemory logLine) => ParseOrMinValue(logLine)); + + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(callback); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); + + _ = locator.FindForward(0, 3, roundToSeconds: false); + + Assert.That(callback.PositionedAt, Is.EqualTo(new[] { 0, 1, 2 })); + } + + [Test] + public void FindLine_ExactHitAtTheMiddleLine_ReturnsThatLine () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02", "2026-01-01 10:00:03", "2026-01-01 10:00:04"); + + var line = locator.FindLine(At("2026-01-01 10:00:02"), fromLine: 2, lineCount: 5, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(2)); + } + + [Test] + public void FindLine_ExactHitAtTheFirstLine_ReturnsLineZero () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); + + var line = locator.FindLine(At("2026-01-01 10:00:00"), fromLine: 1, lineCount: 3, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(0)); + } + + [Test] + public void FindLine_ExactHitAtTheLastLine_ReturnsTheLastLine () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); + + var line = locator.FindLine(At("2026-01-01 10:00:02"), fromLine: 1, lineCount: 3, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(2)); + } + + /// + /// Ported quirk, preserved rather than fixed (per the extraction ticket): a miss is signalled by + /// returning the near-miss line number negated. When the near-miss line is 0, that + /// negation is indistinguishable from an exact hit at line 0 — -0 == 0 for a signed int. + /// Searching before the very first line always converges the binary search on line 0 (the range + /// can never go below it), so this case is never reported as a miss: the wrapper treats it as a + /// hit at line 0, walks back (already at 0), then steps forward to the first line with a real + /// timestamp — line 1 for this fixture. Not a bug introduced by the extraction; the original + /// FindTimestampLine does the same walk starting from the same -lineNum == 0 value. + /// + [Test] + public void FindLine_TimestampBeforeTheFirstLine_IsIndistinguishableFromAHitAtLineZero_StepsForwardToLineOne () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); + + var line = locator.FindLine(At("2025-01-01 00:00:00"), fromLine: 1, lineCount: 3, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(1)); + } + + [Test] + public void FindLine_NoExactMatchMidFile_ReturnsANegatedNearMissAwayFromLineZero () + { + // A miss whose near-miss line is not 0 is unambiguously negative — the -0 collision above + // does not apply here. + var locator = LocatorOver( + "2026-01-01 10:00:00", + "2026-01-01 10:00:01", + "2026-01-01 10:00:02", + "2026-01-01 10:00:03", + "2026-01-01 10:00:04"); + + var line = locator.FindLine(At("2026-01-01 10:00:02.500"), fromLine: 2, lineCount: 5, roundToSeconds: false); + + Assert.That(line, Is.LessThan(0)); + } + + [Test] + public void FindLine_TimestampAfterTheLastLine_ReturnsANegatedNearMiss () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); + + var line = locator.FindLine(At("2027-01-01 00:00:00"), fromLine: 1, lineCount: 3, roundToSeconds: false); + + Assert.That(line, Is.LessThan(0)); + } + + [Test] + public void FindLine_DuplicateTimestampAcrossARunOfLines_ReturnsTheFirstOfTheRun () + { + var locator = LocatorOver( + "2026-01-01 10:00:00", + "2026-01-01 10:00:01", + "2026-01-01 10:00:01", + "2026-01-01 10:00:01", + "2026-01-01 10:00:02"); + + var line = locator.FindLine(At("2026-01-01 10:00:01"), fromLine: 2, lineCount: 5, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(1)); + } + + [TestCase(0, TestName = "FindLine_RangeOfOneLine_FindsIt")] + [TestCase(1, TestName = "FindLine_RangeOfTwoLines_FindsTheSecond")] + public void FindLine_NarrowRange_StillFindsTheExactLine (int targetLine) + { + var lines = targetLine == 0 ? new[] { "2026-01-01 10:00:00" } : new[] { "2026-01-01 10:00:00", "2026-01-01 10:00:01" }; + var locator = LocatorOver(lines); + + var line = locator.FindLine(At(lines[targetLine]), fromLine: 0, lineCount: lines.Length, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(targetLine)); + } + + /// Every line's own timestamp, searched for, must resolve back to the first line + /// carrying that timestamp — the monotonic-file property the binary search exists to serve. + [Test] + public void FindLine_MonotonicFile_EveryLineResolvesBackToItsOwnTimestamp () + { + var lines = Enumerable.Range(0, 30).Select(i => At("2026-01-01 10:00:00").AddSeconds(i).ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)).ToArray(); + var locator = LocatorOver(lines); + + Assert.Multiple(() => + { + for (var i = 0; i < lines.Length; i++) + { + var line = locator.FindLine(At(lines[i]), fromLine: lines.Length / 2, lineCount: lines.Length, roundToSeconds: false); + Assert.That(line, Is.EqualTo(i), $"line {i}"); + } + }); + } + + /// + /// Tests for the raw binary-search step, used directly (not through ) + /// by TimeSpreadCalculator, which does its own walk-back / sign handling for performance + /// reasons. Ported from the original FindTimestampLineInternal — same near-miss-negated + /// contract, minus the "walk back to the first occurrence" step adds. + /// + [Test] + public void FindNearestLine_ExactHitInRange_ReturnsThatLine () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02", "2026-01-01 10:00:03", "2026-01-01 10:00:04"); + + var line = locator.FindNearestLine(At("2026-01-01 10:00:03"), fromLine: 2, rangeStart: 2, rangeEnd: 4, lineCount: 5, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(3)); + } + + [Test] + public void FindNearestLine_NoExactMatchInRange_ReturnsANegatedNearMiss () + { + var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02", "2026-01-01 10:00:03", "2026-01-01 10:00:04"); + + var line = locator.FindNearestLine(At("2026-01-01 10:00:02.500"), fromLine: 2, rangeStart: 2, rangeEnd: 4, lineCount: 5, roundToSeconds: false); + + Assert.That(line, Is.LessThan(0)); + } + + /// + /// Does not walk back to the first line of a duplicate-timestamp run — that is exactly + /// the behaviour adds on top of this primitive. + /// + [Test] + public void FindNearestLine_DuplicateTimestampRun_DoesNotWalkBackToTheFirstOccurrence () + { + var locator = LocatorOver( + "2026-01-01 10:00:00", + "2026-01-01 10:00:01", + "2026-01-01 10:00:01", + "2026-01-01 10:00:01", + "2026-01-01 10:00:02"); + + var line = locator.FindNearestLine(At("2026-01-01 10:00:01"), fromLine: 2, rangeStart: 0, rangeEnd: 4, lineCount: 5, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(2), "lands on whichever line the binary search first hits, not necessarily the first of the run"); + } + + #region Fake source over known lines + + private static TimestampLocator LocatorOver (params string[] lines) => new(SourceOver(lines)); + + /// + /// Builds a source whose Columnizer parses each line as an invariant date-time, or returns + /// MinValue for — the same "no timestamp on this line" signal every real + /// Columnizer gives. + /// + private static ITimestampSource SourceOver (params string[] lines) + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(lines.Length); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNum) => lineNum >= 0 && lineNum < lines.Length ? LineOf(lines[lineNum]) : null!); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + _ = columnizerMock.Setup(c => c.GetTimestamp(It.IsAny(), It.IsAny())) + .Returns((ILogLineMemoryColumnizerCallback _, ILogLineMemory logLine) => ParseOrMinValue(logLine)); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + return sourceMock.Object; + } + + private static DateTime ParseOrMinValue (ILogLineMemory logLine) + { + var text = logLine.FullLine.ToString(); + return text.Length == 0 + ? DateTime.MinValue + : At(text); + } + + /// Parses "yyyy-MM-dd HH:mm:ss" with an optional ".fff" fraction. + private static DateTime At (string text) => DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.None); + + private static ILogLineMemory LineOf (string text) + { + var mock = new Mock(); + _ = mock.Setup(l => l.FullLine).Returns(text.AsMemory()); + return mock.Object; + } + + /// + /// Records every line number the locator positioned it on, so tests can assert the callback is + /// moved before each Columnizer call. + /// + private sealed class RecordingCallback : IPositionedColumnizerCallback + { + public List PositionedAt { get; } = []; + + public int LineNum { get; private set; } + + public void SetLineNum (int lineNum) + { + LineNum = lineNum; + PositionedAt.Add(lineNum); + } + + public string GetFileName () => "fake.log"; + + public int GetLineCount () => 0; + + public ILogLineMemory GetLogLineMemory (int lineNum) => null!; + } + + #endregion +} From 34c48cc0bdeadc8125dad99a785c4c5e1eb53d52 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 18:51:10 +0200 Subject: [PATCH 02/11] refactor: migrate LogWindow and TimeSpreadCalculator onto TimestampLocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tickets 2+3 of the Timestamp Locator extraction. LogWindow implements the new ITimestampSource seam and its timestamp methods become delegations to TimestampLocator; dataGridView.RowCount is replaced by _logFileReader.LineCount at the four lookup sites (it was always just a mirror of the reader's count). ColumnizerCallback now implements IPositionedColumnizerCallback so the locator can position it. TimeSpreadCalculator no longer holds an ILogWindow — the timestamp trio was the only reason it did (closes its own //TODO Refactor that it does not need LogWindow) — and now takes (TimestampLocator, ITimestampSource) instead. GetTimestampForLine/GetTimestampForLineForward/ FindTimestampLineInternal are removed from ILogWindow; the last of those turned out to have no remaining caller once TimeSpreadCalculator was rewired, so LogWindow's own copy is deleted rather than kept private. No ref int remains on the timestamp path. _isTimestampDisplaySyncing is deleted (write-only after the cancellation bail-out moved to the window's CancellationTokenSource, matching the _shouldCancel -> window CTS migration done for the filter path in b7a293a3). Manual smoke (drag-to-scroll, time-sync between tabs, two-row time diff, the spread bar) is not yet verified against a live build — flagging per the ticket's acceptance criteria for a human pass. --- .../Callback/ColumnizerCallback.cs | 2 +- src/LogExpert.Core/Interfaces/ILogWindow.cs | 61 ----- .../Controls/LogWindow/LogWindow.cs | 218 +++--------------- .../LogWindow/TimeSpreadCalculator.cs | 38 +-- 4 files changed, 47 insertions(+), 272 deletions(-) diff --git a/src/LogExpert.Core/Callback/ColumnizerCallback.cs b/src/LogExpert.Core/Callback/ColumnizerCallback.cs index c2e94652..813a04c3 100644 --- a/src/LogExpert.Core/Callback/ColumnizerCallback.cs +++ b/src/LogExpert.Core/Callback/ColumnizerCallback.cs @@ -4,7 +4,7 @@ namespace LogExpert.Core.Callback; -public class ColumnizerCallback (ILogWindow logWindow) : ILogLineMemoryColumnizerCallback, IAutoLogLineMemoryColumnizerCallback +public class ColumnizerCallback (ILogWindow logWindow) : IPositionedColumnizerCallback, IAutoLogLineMemoryColumnizerCallback { #region Fields private readonly ILogWindow _logWindow = logWindow; diff --git a/src/LogExpert.Core/Interfaces/ILogWindow.cs b/src/LogExpert.Core/Interfaces/ILogWindow.cs index 7cc3afb7..cc7ae5af 100644 --- a/src/LogExpert.Core/Interfaces/ILogWindow.cs +++ b/src/LogExpert.Core/Interfaces/ILogWindow.cs @@ -44,67 +44,6 @@ public interface ILogWindow /// line's content and associated metadata. ILogLineMemory GetLogLineMemoryWithWait (int lineNum); - /// - /// Gets the timestamp for the line at or after the specified line number, - /// searching forward through the file. - /// - /// - /// A reference to the line number to start searching from. - /// This value is updated to the line number where the timestamp was found. - /// - /// - /// If true, the returned timestamp is rounded to the nearest second. - /// - /// - /// The timestamp of the line at or after the specified line number, - /// or if no valid timestamp is found. - /// - /// - /// Not all log lines may contain timestamps. This method searches forward - /// from the given line number until it finds a line with a valid timestamp. - /// The parameter is updated to reflect the line - /// where the timestamp was found. - /// - //TODO Find a way to not use a referenced int (https://github.com/LogExperts/LogExpert/issues/404) - DateTime GetTimestampForLineForward (ref int lineNum, bool roundToSeconds); - - /// - /// Gets the timestamp for the line at or before the specified line number, - /// searching backward through the file. - /// second. - /// - /// A reference to the line number to start searching from. This value is updated to the line number where the timestamp was found. - /// true to round the timestamp to the nearest second; otherwise, false to return the precise timestamp. - /// A tuple containing the timestamp for the specified line and the last line number for which a timestamp is - /// available. - /// - /// Not all log lines may contain timestamps. This method searches backward - /// from the given line number until it finds a line with a valid timestamp. - /// the returned tuple contains the lastLineNumber - /// - (DateTime timeStamp, int lastLineNumber) GetTimestampForLine (int lastLineNum, bool roundToSeconds); - - /// - /// Finds the line number that corresponds to the specified timestamp within - /// the given range, using a binary search algorithm. - /// - /// The starting line number for the search. - /// The first line number of the search range (inclusive). - /// The last line number of the search range (inclusive). - /// The timestamp to search for. - /// - /// If true, timestamps are rounded to seconds for comparison. - /// - /// - /// The line number of the line with a timestamp closest to the specified timestamp, - /// or -1 if no matching line is found within the range. - /// - /// - /// This method is used for timestamp-based navigation and synchronization between - /// multiple log windows. It performs a binary search for optimal performance. - /// - int FindTimestampLineInternal (int lineNum, int rangeStart, int rangeEnd, DateTime timestamp, bool roundToSeconds); - /// /// Selects the specified line in the log view and optionally scrolls to make it visible. /// diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index ae3a2131..3ae4c71a 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -18,6 +18,7 @@ using LogExpert.Core.Classes.Log; using LogExpert.Core.Classes.Persister; using LogExpert.Core.Classes.Search; +using LogExpert.Core.Classes.Timestamp; using LogExpert.Core.Config; using LogExpert.Core.Entities; using LogExpert.Core.EventArguments; @@ -41,7 +42,7 @@ namespace LogExpert.UI.Controls.LogWindow; //TODO: Implemented 4 interfaces explicitly. Find them by searching: ILogWindow. [SupportedOSPlatform("windows")] -internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, ILogWindow +internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, ILogWindow, ITimestampSource { #region Fields @@ -103,6 +104,7 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private readonly EventWaitHandle _timeShiftSyncWakeupEvent = new ManualResetEvent(false); private readonly TimeSpreadCalculator _timeSpreadCalc; + private readonly TimestampLocator _timestampLocator; private readonly Lock _timeSyncListLock = new(); @@ -135,7 +137,6 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private bool _isLoadError; private bool _isLoading; private bool _isSearching; - private bool _isTimestampDisplaySyncing; private List _lastFilterLinesList = []; @@ -194,6 +195,7 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b ConfigManager = configManager; //TODO: This should be changed to DI //Thread.CurrentThread.Name = "LogWindowThread"; ColumnizerCallbackObject = new ColumnizerCallback(this); + _timestampLocator = new TimestampLocator(this); FileName = fileName; ForcePersistenceLoading = forcePersistenceLoading; @@ -210,7 +212,7 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b Disposed += OnLogWindowDisposed; Load += OnLogWindowLoad; - _timeSpreadCalc = new TimeSpreadCalculator(this); + _timeSpreadCalc = new TimeSpreadCalculator(_timestampLocator, this); timeSpreadingControl.TimeSpreadCalc = _timeSpreadCalc; timeSpreadingControl.LineSelected += OnTimeSpreadingControlLineSelected; tableLayoutPanel1.ColumnStyles[1].SizeType = SizeType.Absolute; @@ -399,6 +401,18 @@ public bool IsMultiFile public ColumnizerCallback ColumnizerCallbackObject { get; } + #region ITimestampSource + + // Read live rather than captured: _logFileReader is reassigned on load/reload/rollover, and + // CurrentColumnizer is swapped by its own setter whenever the user picks a different one. + // TimestampLocator (and TimeSpreadCalculator through it) must see the current instance of each. + ILogfileReader ITimestampSource.Reader => _logFileReader; + ILogLineMemoryColumnizer ITimestampSource.Columnizer => CurrentColumnizer; + IPositionedColumnizerCallback ITimestampSource.Callback => ColumnizerCallbackObject; + Lock ITimestampSource.ColumnizerLock => _currentColumnizerLock; + + #endregion + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public bool ForcePersistenceLoading { get; set; } @@ -3952,7 +3966,6 @@ private void SyncTimestampDisplayWorker () { Thread.CurrentThread.Name = "SyncTimestampDisplayWorker"; _shouldTimestampDisplaySyncingCancel = false; - _isTimestampDisplaySyncing = true; while (!_shouldTimestampDisplaySyncingCancel) { @@ -5292,10 +5305,8 @@ private void SetTimestampLimits () return; } - var line = 0; - _guiStateArgs.MinTimestamp = GetTimestampForLineForward(ref line, true); - line = dataGridView.RowCount - 1; - (_guiStateArgs.MaxTimestamp, _) = GetTimestampForLine(line, true); + (_guiStateArgs.MinTimestamp, _) = _timestampLocator.FindForward(0, _logFileReader.LineCount, true); + (_guiStateArgs.MaxTimestamp, _) = GetTimestampForLine(dataGridView.RowCount - 1, true); SendGuiStateUpdate(); } @@ -7493,7 +7504,7 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo currentLine = 0; } - var foundLine = FindTimestampLine(currentLine, timestamp, roundToSeconds); + var foundLine = _timestampLocator.FindLine(timestamp, currentLine, _logFileReader.LineCount, roundToSeconds, _windowCts.Token); if (foundLine >= 0) { SelectAndEnsureVisible(foundLine, triggerSyncCall); @@ -7504,189 +7515,14 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo return hasScrolled; } - public int FindTimestampLine (int lineNum, DateTime timestamp, bool roundToSeconds) - { - var foundLine = FindTimestampLineInternal(lineNum, 0, dataGridView.RowCount - 1, timestamp, roundToSeconds); - - if (foundLine >= 0) - { - // go backwards to the first occurence of the hit - var (foundTimestamp, foundLine1) = GetTimestampForLine(foundLine, roundToSeconds); - foundLine = foundLine1; - while (foundTimestamp.CompareTo(timestamp) == 0 && foundLine >= 0) - { - foundLine--; - (foundTimestamp, foundLine1) = GetTimestampForLine(foundLine, roundToSeconds); - foundLine = foundLine1; - } - - if (foundLine < 0) - { - return 0; - } - - foundLine++; - _ = GetTimestampForLineForward(ref foundLine, roundToSeconds); // fwd to next valid timestamp - return foundLine; - } - - return -foundLine; - } - - public int FindTimestampLineInternal (int lineNum, int rangeStart, int rangeEnd, DateTime timestamp, bool roundToSeconds) - { - var (currentTimestamp, foundLine) = GetTimestampForLine(lineNum, roundToSeconds); - if (currentTimestamp.CompareTo(timestamp) == 0) - { - //return lineNum; - return foundLine; - } - - if (timestamp < currentTimestamp) - { - //rangeStart = rangeStart; - rangeEnd = lineNum; - } - else - { - rangeStart = lineNum; - //rangeEnd = rangeEnd; - } - - if (rangeEnd - rangeStart <= 0) - { - return -lineNum; - } - - lineNum = ((rangeEnd - rangeStart) / 2) + rangeStart; - // prevent endless loop - if (rangeEnd - rangeStart < 2) - { - (currentTimestamp, rangeStart) = GetTimestampForLine(rangeStart, roundToSeconds); - if (currentTimestamp.CompareTo(timestamp) == 0) - { - return rangeStart; - } - - (currentTimestamp, rangeEnd) = GetTimestampForLine(rangeEnd, roundToSeconds); - - return currentTimestamp.CompareTo(timestamp) == 0 - ? rangeEnd - : -lineNum; - } - - return FindTimestampLineInternal(lineNum, rangeStart, rangeEnd, timestamp, roundToSeconds); - } - - /** - * Get the timestamp for the given line number. If the line - * has no timestamp, the previous line will be checked until a - * timestamp is found. -*/ - public (DateTime timeStamp, int lastLineNumber) GetTimestampForLine (int lastLineNum, bool roundToSeconds) - { - lock (_currentColumnizerLock) - { - if (!CurrentColumnizer.IsTimeshiftImplemented()) - { - return (DateTime.MinValue, lastLineNum); - } - - if (_logger.IsDebugEnabled) - { - _logger.Debug($"### GetTimestampForLine: leave with lineNum={lastLineNum}"); - } - - var timeStamp = DateTime.MinValue; - var lookBack = false; - if (lastLineNum >= 0 && lastLineNum < dataGridView.RowCount) - { - while (timeStamp.CompareTo(DateTime.MinValue) == 0 && lastLineNum >= 0) - { - if (_isTimestampDisplaySyncing && _shouldTimestampDisplaySyncingCancel) - { - return (DateTime.MinValue, lastLineNum); - } - - lookBack = true; - var logLine = _logFileReader.GetLogLineMemory(lastLineNum); - if (logLine == null) - { - return (DateTime.MinValue, lastLineNum); - } - - ColumnizerCallbackObject.LineNum = lastLineNum; - timeStamp = CurrentColumnizer.GetTimestamp(ColumnizerCallbackObject, logLine); - if (roundToSeconds) - { - timeStamp = timeStamp.Subtract(TimeSpan.FromMilliseconds(timeStamp.Millisecond)); - } - - lastLineNum--; - } - } - - if (lookBack) - { - lastLineNum++; - } - - if (_logger.IsDebugEnabled) - { - _logger.Debug($"### GetTimestampForLine: found timestamp={timeStamp}"); - } - - return (timeStamp, lastLineNum); - } - } - - /** - * Get the timestamp for the given line number. If the line - * has no timestamp, the next line will be checked until a - * timestamp is found. -*/ - public DateTime GetTimestampForLineForward (ref int lineNum, bool roundToSeconds) + /// + /// Gets the timestamp for the line at or before , searching + /// backward through the file for one if that line has none. Delegates to + /// . + /// + private (DateTime timeStamp, int lastLineNumber) GetTimestampForLine (int lastLineNum, bool roundToSeconds) { - lock (_currentColumnizerLock) - { - if (!CurrentColumnizer.IsTimeshiftImplemented()) - { - return DateTime.MinValue; - } - - var timeStamp = DateTime.MinValue; - var lookFwd = false; - if (lineNum >= 0 && lineNum < dataGridView.RowCount) - { - while (timeStamp.CompareTo(DateTime.MinValue) == 0 && lineNum < dataGridView.RowCount) - { - lookFwd = true; - var logLine = _logFileReader.GetLogLineMemory(lineNum); - - if (logLine == null) - { - timeStamp = DateTime.MinValue; - break; - } - - timeStamp = CurrentColumnizer.GetTimestamp(ColumnizerCallbackObject, logLine); - - if (roundToSeconds) - { - timeStamp = timeStamp.Subtract(TimeSpan.FromMilliseconds(timeStamp.Millisecond)); - } - - lineNum++; - } - } - - if (lookFwd) - { - lineNum--; - } - - return timeStamp; - } + return _timestampLocator.FindBackward(lastLineNum, _logFileReader.LineCount, roundToSeconds, _windowCts.Token); } public void AppFocusLost () diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs index 6af054a5..55dcb354 100644 --- a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs +++ b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs @@ -1,6 +1,5 @@ -using LogExpert.Core.Callback; using LogExpert.Core.Classes; -using LogExpert.Core.Interfaces; +using LogExpert.Core.Classes.Timestamp; namespace LogExpert.UI.Controls.LogWindow; @@ -13,13 +12,12 @@ internal class TimeSpreadCalculator private const int MAX_CONTRAST = 1300; private readonly EventWaitHandle _calcEvent = new ManualResetEvent(false); - private readonly ColumnizerCallback _callback; private readonly Lock _diffListLock = new(); private readonly EventWaitHandle _lineCountEvent = new ManualResetEvent(false); - //TODO Refactor that it does not need LogWindow - private readonly ILogWindow _logWindow; + private readonly TimestampLocator _locator; + private readonly ITimestampSource _source; // for DoCalc_via_Time private double _average; @@ -46,10 +44,10 @@ internal class TimeSpreadCalculator #region cTor - public TimeSpreadCalculator (ILogWindow logWindow) + public TimeSpreadCalculator (TimestampLocator locator, ITimestampSource source) { - _logWindow = logWindow; - _callback = new ColumnizerCallback(_logWindow); + _locator = locator; + _source = source; _ = Task.Run(WorkerFx, _cts.Token); } @@ -199,16 +197,17 @@ private void DoCalc () { OnStartCalc(EventArgs.Empty); - if (_callback.GetLineCount() < 1) + var lineCount = _source.Reader.LineCount; + if (lineCount < 1) { OnCalcDone(EventArgs.Empty); return; } var lineNum = 0; - var lastLineNum = _callback.GetLineCount() - 1; - _startTimestamp = _logWindow.GetTimestampForLineForward(ref lineNum, false); - (_endTimestamp, lastLineNum) = _logWindow.GetTimestampForLine(lastLineNum, false); + var lastLineNum = lineCount - 1; + (_startTimestamp, lineNum) = _locator.FindForward(lineNum, lineCount, false); + (_endTimestamp, lastLineNum) = _locator.FindBackward(lastLineNum, lineCount, false); var timePerLineSum = 0; @@ -217,7 +216,7 @@ private void DoCalc () var overallSpan = _endTimestamp - _startTimestamp; var overallSpanMillis = (int)(overallSpan.Ticks / TimeSpan.TicksPerMillisecond); _timePerLine = (int)Math.Round(overallSpanMillis / (double)_lineCount); - var oldTime = _logWindow.GetTimestampForLineForward(ref lineNum, false); + (var oldTime, lineNum) = _locator.FindForward(lineNum, lineCount, false); var step = _lineCount > _displayHeight ? (int)Math.Round(_lineCount / (double)_displayHeight) : 1; @@ -231,7 +230,7 @@ private void DoCalc () for (var i = lineNum; i < lastLineNum; i += step) { var currLineNum = i; - var time = _logWindow.GetTimestampForLineForward(ref currLineNum, false); + (var time, currLineNum) = _locator.FindForward(currLineNum, lineCount, false); if (time != DateTime.MinValue) { var span = time - oldTime; @@ -262,7 +261,8 @@ private void DoCalcViaTime () { OnStartCalc(EventArgs.Empty); - if (_callback.GetLineCount() < 1) + var lineCount = _source.Reader.LineCount; + if (lineCount < 1) { OnCalcDone(EventArgs.Empty); //_logger.Debug($"End because of line count < 1"); @@ -270,9 +270,9 @@ private void DoCalcViaTime () } var lineNum = 0; - var lastLineNum = _callback.GetLineCount() - 1; - _startTimestamp = _logWindow.GetTimestampForLineForward(ref lineNum, false); - (_endTimestamp, lastLineNum) = _logWindow.GetTimestampForLine(lastLineNum, false); + var lastLineNum = lineCount - 1; + (_startTimestamp, lineNum) = _locator.FindForward(lineNum, lineCount, false); + (_endTimestamp, lastLineNum) = _locator.FindBackward(lastLineNum, lineCount, false); if (_startTimestamp != DateTime.MinValue && _endTimestamp != DateTime.MinValue) { @@ -296,7 +296,7 @@ private void DoCalcViaTime () while (searchTimeStamp.CompareTo(_endTimestamp) <= 0) { - lineNum = _logWindow.FindTimestampLineInternal(lineNum, lineNum, lastLineNum, searchTimeStamp, false); + lineNum = _locator.FindNearestLine(searchTimeStamp, lineNum, lineNum, lastLineNum, lineCount, false); if (lineNum < 0) { lineNum = -lineNum; From 720ae73ce6eda0257456092e057e144c850458f5 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 18:51:17 +0200 Subject: [PATCH 03/11] docs: record Timestamp Locator in CONTEXT.md glossary Ticket 4 of the Timestamp Locator extraction: Timestamp Locator, Timestamp Source, and the negated-near-miss return convention (including the -0/line-zero ambiguity, a preserved quirk of the ported algorithm). Issue #404 (referenced int on ILogWindow) was already closed upstream; this branch removes its last actual occurrence in code. docs/improve/logwindow-architecture-review.html (gitignored, not part of this commit) is updated separately to mark candidate 6 shipped. --- CONTEXT.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index ae086aa2..c2a92120 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -165,6 +165,41 @@ delegate name, deleted). **filtering** — the `FilterSearch` methods in the code belong to the filter path, not Log Search), "find dialog" (use **Search dialog**). +## Timestamp lookup + +- **Timestamp Locator** (`LogExpert.Core.Classes.Timestamp.TimestampLocator`) + — Timestamp lookup over a Logfile Reader and the active Columnizer: what + time is the line at N (`FindBackward`, `FindForward`), and which line + carries time T (`FindLine`, plus the raw binary-search primitive + `FindNearestLine` used directly by the Time Spread calculator). Pure — + no dependency on the Log Window control, only on `ITimestampSource` + (below). Backs both time-sync (drag-to-scroll, "scroll all tabs to + timestamp", the sync-group worker) and the Time Spread bar; the worker + threads, grid selection, and cross-window coordination stay in the Log + Window and `TimeSyncList` — only the lookup was extracted. +- **Timestamp Source** (`ITimestampSource`) — The narrow seam a Timestamp + Locator runs against: a Logfile Reader, the active Columnizer, a + positionable Columnizer callback, and the lock guarding Columnizer + swaps. Every member is read live rather than captured, because a Log + Window replaces its Logfile Reader on load/reload/rollover and swaps + its Columnizer whenever the user picks a different one; a consumer like + the Time Spread calculator outlives both events. Implemented by the Log + Window itself. +- **Negated near-miss** — The Timestamp Locator's miss convention: when + `FindLine` (or `FindNearestLine`) finds no line carrying the exact + timestamp searched for, it returns the nearest line's number *negated*. + Callers that care about a miss must flip the sign back themselves + (`TimeSpreadCalculator` does). A near-miss that lands on line 0 is + indistinguishable from a hit at line 0 (`-0 == 0`) — a ported quirk of + the original binary search, preserved rather than fixed; see + `TimestampLocatorTests.FindLine_TimestampBeforeTheFirstLine_*`. + +*Avoid*: "GetTimestampForLine" / "GetTimestampForLineForward" / +"FindTimestampLineInternal" as concept names — those were the pre-extraction +Log Window method names (candidate 6 of +`docs/improve/logwindow-architecture-review.html`); the Core module is the +**Timestamp Locator**. + ## Columnizer selection - **Columnizer** (`ILogLineMemoryColumnizer`) — A plugin that parses a log From 7a05dd35f5b50ec72788f914b1930f457395b53f Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 19:02:28 +0200 Subject: [PATCH 04/11] fix: timestamp-lookup cancellation used the wrong CancellationTokenSource Code review (Standards axis) on the Timestamp Locator extraction found a real shutdown-latency regression: GetTimestampForLine's scan and FindTimestampLine's binary search were wired to _windowCts, which is only cancelled late, in CloseLogWindow. The original code (and StopTimestampSyncThread) cancelled via _shouldTimestampDisplaySyncingCancel / _cts, well before JoinWorker blocks. Using _windowCts meant an in-progress scan on SyncTimestampDisplayWorker had no live cancellation signal for the whole of that teardown wait, riding out the full WORKER_SHUTDOWN_TIMEOUT instead of exiting promptly. Switched both call sites to _cts, matching the original bail-out exactly, with a comment explaining why. Also fills two test gaps the spec review found in the Ticket 1 test matrix: roundToSeconds=false preserving sub-second precision (both scan directions), a negative-lineNum case for FindForward (only the above-range case existed), and strengthens the cancellation test to assert the reader is never touched once the token is already cancelled. --- .../Timestamp/TimestampLocatorTests.cs | 51 ++++++++++++++++++- .../Controls/LogWindow/LogWindow.cs | 13 ++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs index 3faf6be6..2951fdd1 100644 --- a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs +++ b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs @@ -169,9 +169,31 @@ public void FindBackward_RoundToSeconds_ZeroesTheMillisecondComponent () } [Test] - public void FindBackward_CancelledToken_StopsScanningAndReturnsMinValue () + public void FindBackward_NotRoundToSeconds_PreservesTheMillisecondComponent () { - var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01"); + var locator = LocatorOver("2026-01-01 10:00:00.750"); + + var (timestamp, _) = locator.FindBackward(0, 1, roundToSeconds: false); + + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:00.750"))); + } + + [Test] + public void FindBackward_CancelledToken_StopsScanningWithoutTouchingTheReader () + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(2); + + var columnizerMock = new Mock(); + _ = columnizerMock.Setup(c => c.IsTimeshiftImplemented()).Returns(true); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(columnizerMock.Object); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + var locator = new TimestampLocator(sourceMock.Object); using var cts = new CancellationTokenSource(); cts.Cancel(); @@ -182,6 +204,7 @@ public void FindBackward_CancelledToken_StopsScanningAndReturnsMinValue () Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); Assert.That(lineNumber, Is.EqualTo(1)); }); + readerMock.Verify(r => r.GetLogLineMemory(It.IsAny()), Times.Never); } [Test] @@ -263,6 +286,20 @@ public void FindForward_LineNumberAtOrAboveLineCount_ReturnsMinValueWithoutScann }); } + [Test] + public void FindForward_NegativeLineNumber_ReturnsMinValueWithoutScanning () + { + var locator = LocatorOver("2026-01-01 10:00:00"); + + var (timestamp, lineNumber) = locator.FindForward(-1, 1, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(DateTime.MinValue)); + Assert.That(lineNumber, Is.EqualTo(-1)); + }); + } + [Test] public void FindForward_RoundToSeconds_ZeroesTheMillisecondComponent () { @@ -273,6 +310,16 @@ public void FindForward_RoundToSeconds_ZeroesTheMillisecondComponent () Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:00.000"))); } + [Test] + public void FindForward_NotRoundToSeconds_PreservesTheMillisecondComponent () + { + var locator = LocatorOver("2026-01-01 10:00:00.750"); + + var (timestamp, _) = locator.FindForward(0, 1, roundToSeconds: false); + + Assert.That(timestamp, Is.EqualTo(At("2026-01-01 10:00:00.750"))); + } + [Test] public void FindForward_ReaderReturnsNullLine_ReturnsMinValueAndStops () { diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 3ae4c71a..48766a8a 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -7504,7 +7504,7 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo currentLine = 0; } - var foundLine = _timestampLocator.FindLine(timestamp, currentLine, _logFileReader.LineCount, roundToSeconds, _windowCts.Token); + var foundLine = _timestampLocator.FindLine(timestamp, currentLine, _logFileReader.LineCount, roundToSeconds, _cts.Token); if (foundLine >= 0) { SelectAndEnsureVisible(foundLine, triggerSyncCall); @@ -7520,9 +7520,18 @@ public bool ScrollToTimestampWorker (DateTime timestamp, bool roundToSeconds, bo /// backward through the file for one if that line has none. Delegates to /// . /// + /// + /// Cancels via , not : this mirrors the original + /// bail-out (_isTimestampDisplaySyncing && _shouldTimestampDisplaySyncingCancel), + /// which every caller of this method observed. _cts is cancelled in + /// — before it blocks on — while + /// _windowCts is only cancelled afterward, in . Using + /// _windowCts here would leave an in-progress scan unable to observe cancellation for the + /// whole of that teardown wait. + /// private (DateTime timeStamp, int lastLineNumber) GetTimestampForLine (int lastLineNum, bool roundToSeconds) { - return _timestampLocator.FindBackward(lastLineNum, _logFileReader.LineCount, roundToSeconds, _windowCts.Token); + return _timestampLocator.FindBackward(lastLineNum, _logFileReader.LineCount, roundToSeconds, _cts.Token); } public void AppFocusLost () From 7ee13048e5a3c75572b1f45c9ddb9666776a44c6 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 19:53:42 +0200 Subject: [PATCH 05/11] fix: FindLine returned a negated miss to callers that expect the nearest line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual smoke on the Timestamp Locator extraction found "Scroll all tabs to current timestamp" and cross-window time-sync doing nothing at all. Root cause: a sign error in the port. The original FindTimestampLine ended with `return -foundLine` — the internal binary search reports a miss as the negated near-miss line, and the public method flipped it BACK to a positive, scrollable line number. The port misread that as "the negation is the public contract" and passed the negative through, so ScrollToTimestampWorker's `foundLine >= 0` check skipped scrolling on every inexact timestamp. Cross-window sync compares timestamps at millisecond precision (roundToSeconds: false), so an exact hit in a different file essentially never happens — every sync path was a no-op. FindLine now flips a miss to the positive converged line, matching the original exactly. FindNearestLine (used by TimeSpreadCalculator, which flips the sign itself) keeps the raw negated convention. The two mis-pinned tests now encode scroll-to-nearest with exact expected lines traced from the original algorithm, and CONTEXT.md's "Negated near-miss" entry now states which method carries which convention. --- CONTEXT.md | 20 ++++++---- .../Classes/Timestamp/TimestampLocator.cs | 11 +++-- .../Timestamp/TimestampLocatorTests.cs | 40 ++++++++++++------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index c2a92120..b4e1eaad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -185,13 +185,19 @@ filter path, not Log Search), "find dialog" (use **Search dialog**). its Columnizer whenever the user picks a different one; a consumer like the Time Spread calculator outlives both events. Implemented by the Log Window itself. -- **Negated near-miss** — The Timestamp Locator's miss convention: when - `FindLine` (or `FindNearestLine`) finds no line carrying the exact - timestamp searched for, it returns the nearest line's number *negated*. - Callers that care about a miss must flip the sign back themselves - (`TimeSpreadCalculator` does). A near-miss that lands on line 0 is - indistinguishable from a hit at line 0 (`-0 == 0`) — a ported quirk of - the original binary search, preserved rather than fixed; see +- **Negated near-miss** — The miss convention of `FindNearestLine`, the raw + binary-search primitive: when no line carries the exact timestamp searched + for, it returns the nearest line's number *negated*, and callers flip the + sign back themselves (`TimeSpreadCalculator` does). `FindLine`, the + high-level lookup, does **not** expose this: it flips a miss back to a + normal positive line number, so callers scroll to the nearest line instead + of doing nothing — cross-window time-sync compares timestamps at + millisecond precision, making the near-miss its *common* case. (Getting + this wrong silently no-opped "scroll all tabs to timestamp" and time-sync; + regression-pinned in `TimestampLocatorTests.FindLine_NoExactMatchMidFile_*`.) + A near-miss that lands on line 0 is indistinguishable from a hit at line 0 + (`-0 == 0`) — a ported quirk of the original binary search, preserved + rather than fixed; see `TimestampLocatorTests.FindLine_TimestampBeforeTheFirstLine_*`. *Avoid*: "GetTimestampForLine" / "GetTimestampForLineForward" / diff --git a/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs b/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs index bbbc13e9..01c8255d 100644 --- a/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs +++ b/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs @@ -128,9 +128,12 @@ public sealed class TimestampLocator (ITimestampSource source) /// Checked by the underlying scans; a cancelled token unwinds the search early. /// /// The line number of the first line carrying . If no line carries - /// it exactly, returns the negated line number nearest the search — callers that care - /// about a miss must flip the sign back themselves; this mirrors the ported behaviour of the - /// original FindTimestampLine. + /// it exactly, returns the line the search converged nearest to, as a normal positive + /// line number — a miss degrades to "scroll here instead", it is not reported. This mirrors the + /// original FindTimestampLine, whose final return -foundLine flipped the + /// internal negated miss back to a scrollable line; cross-window time-sync compares timestamps + /// at millisecond precision, so the nearest-line path is its common case, not its edge case. + /// Callers that need the raw miss signal use . /// public int FindLine (DateTime timestamp, int fromLine, int lineCount, bool roundToSeconds, CancellationToken token = default) { @@ -138,7 +141,7 @@ public int FindLine (DateTime timestamp, int fromLine, int lineCount, bool round if (foundLine < 0) { - return foundLine; + return -foundLine; } // Walk backward to the first line of the run sharing this exact timestamp. diff --git a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs index 2951fdd1..6447b047 100644 --- a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs +++ b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs @@ -442,14 +442,15 @@ public void FindLine_ExactHitAtTheLastLine_ReturnsTheLastLine () } /// - /// Ported quirk, preserved rather than fixed (per the extraction ticket): a miss is signalled by - /// returning the near-miss line number negated. When the near-miss line is 0, that - /// negation is indistinguishable from an exact hit at line 0 — -0 == 0 for a signed int. - /// Searching before the very first line always converges the binary search on line 0 (the range - /// can never go below it), so this case is never reported as a miss: the wrapper treats it as a - /// hit at line 0, walks back (already at 0), then steps forward to the first line with a real - /// timestamp — line 1 for this fixture. Not a bug introduced by the extraction; the original - /// FindTimestampLine does the same walk starting from the same -lineNum == 0 value. + /// Ported quirk, preserved rather than fixed (per the extraction ticket): the internal binary + /// search signals a miss by returning the near-miss line number negated. When the + /// near-miss line is 0, that negation is indistinguishable from an exact hit at line 0 — + /// -0 == 0 for a signed int. Searching before the very first line always converges the + /// binary search on line 0 (the range can never go below it), so this case takes the + /// hit branch of : it walks back (already at 0), + /// then steps forward to the first line with a real timestamp — line 1 for this fixture. Not a + /// bug introduced by the extraction; the original FindTimestampLine does the same walk + /// starting from the same -lineNum == 0 value. /// [Test] public void FindLine_TimestampBeforeTheFirstLine_IsIndistinguishableFromAHitAtLineZero_StepsForwardToLineOne () @@ -461,11 +462,18 @@ public void FindLine_TimestampBeforeTheFirstLine_IsIndistinguishableFromAHitAtLi Assert.That(line, Is.EqualTo(1)); } + /// + /// The scroll-to-nearest contract, regression-pinned after a smoke test caught it broken: + /// on a miss, flips the internal negated near-miss back + /// to a positive line number — the original FindTimestampLine ended with + /// return -foundLine. Cross-window time-sync compares timestamps at millisecond + /// precision, so an exact hit in another window's file is the rare case; if a miss stayed + /// negative, "Scroll all tabs to current timestamp" and scrollbar time-sync would do nothing + /// at all (the caller ignores negative lines) instead of scrolling to the nearest line. + /// [Test] - public void FindLine_NoExactMatchMidFile_ReturnsANegatedNearMissAwayFromLineZero () + public void FindLine_NoExactMatchMidFile_ReturnsTheNearestLineAsAPositiveNumber () { - // A miss whose near-miss line is not 0 is unambiguously negative — the -0 collision above - // does not apply here. var locator = LocatorOver( "2026-01-01 10:00:00", "2026-01-01 10:00:01", @@ -475,17 +483,21 @@ public void FindLine_NoExactMatchMidFile_ReturnsANegatedNearMissAwayFromLineZero var line = locator.FindLine(At("2026-01-01 10:00:02.500"), fromLine: 2, lineCount: 5, roundToSeconds: false); - Assert.That(line, Is.LessThan(0)); + // The binary search converges on line 2 (10:00:02) for 10:00:02.500 — nearest, flipped positive. + Assert.That(line, Is.EqualTo(2)); } [Test] - public void FindLine_TimestampAfterTheLastLine_ReturnsANegatedNearMiss () + public void FindLine_TimestampAfterTheLastLine_ReturnsTheConvergedLineAsAPositiveNumber () { var locator = LocatorOver("2026-01-01 10:00:00", "2026-01-01 10:00:01", "2026-01-01 10:00:02"); var line = locator.FindLine(At("2027-01-01 00:00:00"), fromLine: 1, lineCount: 3, roundToSeconds: false); - Assert.That(line, Is.LessThan(0)); + // Ported quirk: the search reports the last midpoint it converged on (line 1), not the + // truly nearest line (line 2). The original behaved identically; callers only need + // "somewhere close, and scroll" — not exact nearest. + Assert.That(line, Is.EqualTo(1)); } [Test] From 00e370e547259fa739468f254527285ae18a0665 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 20:23:37 +0200 Subject: [PATCH 06/11] test: pin the timestamp pipeline against the real TimestampColumnizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report of dead time-sync on "yyyy-MM-dd HH:mm:ss.fff | LEVEL | thread | source | message" lines. The locator's test matrix so far used only a stub columnizer, so it could not distinguish "lookup broken" from "columnizer can't parse this format". Three integration tests now run the real TimestampColumnizer over lines in exactly that shape: FindBackward parses the millisecond timestamp, FindLine hits an exact millisecond, and a millisecond miss (the cross-window sync common case) still returns a positive, scrollable line. All pass — so a dead sync on this format points at the UI gates (timestamp-control preference, per-window columnizer selection, sync-group membership), not at the parse or lookup pipeline. --- .../Timestamp/TimestampLocatorTests.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs index 6447b047..5aa58c88 100644 --- a/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs +++ b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs @@ -4,6 +4,7 @@ using ColumnizerLib; +using LogExpert.Core.Classes.Columnizer; using LogExpert.Core.Classes.Timestamp; using LogExpert.Core.Interfaces; @@ -590,6 +591,78 @@ public void FindNearestLine_DuplicateTimestampRun_DoesNotWalkBackToTheFirstOccur Assert.That(line, Is.EqualTo(2), "lands on whichever line the binary search first hits, not necessarily the first of the run"); } + #region Integration: real TimestampColumnizer over pipe-separated log lines + + /// + /// End-to-end through the real (not the stub) with lines + /// shaped like a real pipe-separated log — the exact format a user reported dead time-sync on + /// ("yyyy-MM-dd HH:mm:ss.fff | LEVEL | thread | source | message"). Proves the parse + lookup + /// pipeline: if these pass, a dead sync in the app is a UI gate (timestamp-control preference, + /// sync-group membership, per-window columnizer choice), not the locator or the columnizer. + /// + [Test] + public void RealTimestampColumnizer_PipeSeparatedTraceLine_FindBackwardParsesTheTimestamp () + { + var locator = RealColumnizerLocatorOver( + "2022-03-21 11:34:34.491 | TRACE | 100 | CTI60Controller | <-- SendRadioInterfaceChanged", + "2022-03-21 11:34:34.505 | DEBUG | 33 | PositionServiceListener | --> RadioInterfaceUpdate: message = [[; UpdateNumber=360]]", + "2022-03-21 11:34:34.532 | TRACE | 21 | PositionServiceListener | ### ProcessRadioInterfaceUpdates: performing update"); + + var (timestamp, lineNumber) = locator.FindBackward(1, 3, roundToSeconds: false); + + Assert.Multiple(() => + { + Assert.That(timestamp, Is.EqualTo(At("2022-03-21 11:34:34.505"))); + Assert.That(lineNumber, Is.EqualTo(1)); + }); + } + + [Test] + public void RealTimestampColumnizer_ExactMillisecondHit_FindLineReturnsTheLine () + { + var locator = RealColumnizerLocatorOver( + "2022-03-21 11:34:34.491 | TRACE | 100 | CTI60Controller | <-- SendRadioInterfaceChanged", + "2022-03-21 11:34:34.505 | DEBUG | 33 | PositionServiceListener | --> RadioInterfaceUpdate", + "2022-03-21 11:34:34.532 | TRACE | 21 | PositionServiceListener | ### ProcessRadioInterfaceUpdates"); + + var line = locator.FindLine(At("2022-03-21 11:34:34.505"), fromLine: 0, lineCount: 3, roundToSeconds: false); + + Assert.That(line, Is.EqualTo(1)); + } + + [Test] + public void RealTimestampColumnizer_MillisecondMiss_FindLineStillReturnsAScrollableLine () + { + // The cross-window sync case: the source window's exact millisecond almost never exists + // in the target file. The result must be positive (scrollable), never a negated miss. + var locator = RealColumnizerLocatorOver( + "2022-03-21 11:34:34.491 | TRACE | 100 | CTI60Controller | <-- SendRadioInterfaceChanged", + "2022-03-21 11:34:34.532 | TRACE | 21 | PositionServiceListener | ### ProcessRadioInterfaceUpdates", + "2022-03-21 11:34:35.104 | TRACE | 21 | Mixers | <-- Devices"); + + var line = locator.FindLine(At("2022-03-21 11:34:34.505"), fromLine: 0, lineCount: 3, roundToSeconds: false); + + Assert.That(line, Is.InRange(0, 2)); + } + + private static TimestampLocator RealColumnizerLocatorOver (params string[] lines) + { + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(lines.Length); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNum) => lineNum >= 0 && lineNum < lines.Length ? LineOf(lines[lineNum]) : null!); + + var sourceMock = new Mock(); + _ = sourceMock.Setup(s => s.Reader).Returns(readerMock.Object); + _ = sourceMock.Setup(s => s.Columnizer).Returns(new TimestampColumnizer()); + _ = sourceMock.Setup(s => s.Callback).Returns(new RecordingCallback()); + _ = sourceMock.Setup(s => s.ColumnizerLock).Returns(new Lock()); + + return new TimestampLocator(sourceMock.Object); + } + + #endregion + #region Fake source over known lines private static TimestampLocator LocatorOver (params string[] lines) => new(SourceOver(lines)); From 88448fc9b3b4f6aef424905b8a112693b3dff4bf Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 20:49:24 +0200 Subject: [PATCH 07/11] feat: flash tab LEDs when selection-driven time-sync scrolls other windows The two time-sync paths signalled differently: "Scroll all tabs to current timestamp" (coordinator) pinged the LED indicator service for every tab it scrolled, but selection-driven sync (worker -> SyncOtherWindows -> TimeSyncList.NavigateToTimestamp) scrolled silently, so users got no visual cue which tabs followed. Pre-existing asymmetry, surfaced while smoke-testing the Timestamp Locator branch. TimeSyncList.NavigateToTimestamp now takes an optional onScrolled callback invoked per window that accepted the scroll, and SyncOtherWindows routes it to the coordinator's new IndicateTimeSyncActivity - the same DIFF_MAX activity signal the menu path raises, so both paths are visually identical. The LED service is safe to call from the sync worker thread: it locks internally and marshals icon updates to the UI thread. Note: from the worker thread, ScrollToTimestamp dispatches via BeginInvoke and reports acceptance rather than completion, so the LED flashes for every sync-group member - documented on the callback. --- src/LogExpert.UI/Controls/LogWindow/LogWindow.cs | 2 +- src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs | 12 +++++++++--- src/LogExpert.UI/Interface/ILogWindowCoordinator.cs | 9 +++++++++ .../LogWindowCoordinator.cs | 7 ++++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 48766a8a..da741472 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -5592,7 +5592,7 @@ private void SyncOtherWindows (DateTime timestamp) { lock (_timeSyncListLock) { - TimeSyncList?.NavigateToTimestamp(timestamp, this); + TimeSyncList?.NavigateToTimestamp(timestamp, this, _logWindowCoordinator.IndicateTimeSyncActivity); } } diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs index 8faa997e..a9c92032 100644 --- a/src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs +++ b/src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs @@ -59,17 +59,23 @@ public void RemoveWindow (LogWindow logWindow) /// /// /// + /// + /// Invoked for each window that accepted the scroll (LED signalling). When this is called from + /// the sender's sync worker thread, a cross-thread scroll is dispatched via BeginInvoke and + /// reports acceptance, not completion — the callback then covers every sync-group member, + /// which is the intended "these tabs are moving" signal. + /// [SupportedOSPlatform("windows")] - public void NavigateToTimestamp (DateTime timestamp, LogWindow sender) + public void NavigateToTimestamp (DateTime timestamp, LogWindow sender, Action onScrolled = null) { CurrentTimestamp = timestamp; lock (logWindowList) { foreach (var logWindow in logWindowList) { - if (sender != logWindow) + if (sender != logWindow && logWindow.ScrollToTimestamp(timestamp, false, false)) { - _ = logWindow.ScrollToTimestamp(timestamp, false, false); + onScrolled?.Invoke(logWindow); } } } diff --git a/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs b/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs index 2e380126..8299aef4 100644 --- a/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs +++ b/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs @@ -65,6 +65,15 @@ internal interface ILogWindowCoordinator /// void ScrollAllTabsToTimestamp (DateTime timestamp, LogWindow sender); + /// + /// Flashes the given window's tab LED to signal it was scrolled by time-sync. + /// Same activity signal that raises for the tabs it + /// scrolls; selection-driven sync (via TimeSyncList) reports through here so both sync + /// paths are visually identical. Safe to call from a worker thread — the LED service marshals + /// icon updates to the UI thread itself. + /// + void IndicateTimeSyncActivity (LogWindow logWindow); + /// /// Returns the list of all currently open log files. /// diff --git a/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs b/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs index c4a15d91..c87b0e8a 100644 --- a/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs +++ b/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs @@ -189,11 +189,16 @@ public void ScrollAllTabsToTimestamp (DateTime timestamp, LogWindow sender) { if (logWindow.ScrollToTimestamp(timestamp, false, false)) { - _ledIndicatorService.UpdateWindowActivity(logWindow, DIFF_MAX); + IndicateTimeSyncActivity(logWindow); } } } + public void IndicateTimeSyncActivity (LogWindow logWindow) + { + _ledIndicatorService.UpdateWindowActivity(logWindow, DIFF_MAX); + } + public IList GetOpenFiles () { IList list = []; From 11ac46b99c84499c6ca7671c6e1206e53f9298fa Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 21:01:53 +0200 Subject: [PATCH 08/11] feat: hint at the timestamp-control setting when enabling time-sync Selection-driven time-sync (mouse click / arrow keys) only runs when Preferences.TimestampControl is on - and it defaults to off. Users who time-sync files with it off see "Scroll all tabs to current timestamp" work while selecting lines does nothing, which reads as a broken feature rather than a disabled setting; that exact confusion cost a real debugging session during the Timestamp Locator smoke test. When a window is added to a time-sync group and the setting is off, an information box now points at Settings > Timestamp features > "Show timestamp control". Shown once per application run, at the moment sync is switched on - the action whose outcome the setting silently changes. English and German resources. --- src/LogExpert.Resources/Resources.Designer.cs | 11 ++++++++++ src/LogExpert.Resources/Resources.de.resx | 5 +++++ src/LogExpert.Resources/Resources.resx | 5 +++++ .../Controls/LogWindow/LogWindow.cs | 22 +++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 9dcec2fe..830b727a 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -3640,6 +3640,17 @@ public static string LogWindow_UI_ThereAreSomeCommentsInTheBookmarksReallyRemove } } + /// + /// Looks up a localized string similar to Files are now time synced. + /// + ///To sync the other windows while you select lines (mouse click or arrow keys), enable "Show timestamp control" under Settings > Timestamp features. Without it, only "Scroll all tabs to current timestamp" moves the other windows.. + /// + public static string LogWindow_UI_TimeSync_TimestampControlHint { + get { + return ResourceManager.GetString("LogWindow_UI_TimeSync_TimestampControlHint", resourceCulture); + } + } + /// /// Looks up a localized string similar to Choose a file to save bookmarks into. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 1d076172..75ce0941 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -401,6 +401,11 @@ Zeitsynchronisierte Dateien + + Die Dateien sind jetzt zeitsynchronisiert. + +Damit die anderen Fenster beim Auswählen einer Zeile (Mausklick oder Pfeiltasten) mitlaufen, aktiviere "Show timestamp control" unter Einstellungen > Timestamp features. Ohne diese Option bewegt nur "Scroll all tabs to current timestamp" die anderen Fenster. + Temp Highlights diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index b5e64674..1d6d3c85 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -422,6 +422,11 @@ Scolls all open tabs to the selected timestamp, if possible + + Files are now time synced. + +To sync the other windows while you select lines (mouse click or arrow keys), enable "Show timestamp control" under Settings > Timestamp features. Without it, only "Scroll all tabs to current timestamp" moves the other windows. + Time synced files diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index da741472..ed31a340 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -1607,10 +1607,32 @@ private void OnHandleSyncContextMenu (object sender, EventArgs args) //AddSlaveToTimesync(entry.LogWindow); { AddOtherWindowToTimesync(entry.LogWindow); + ShowTimeSyncSettingsHintOnce(); } } } + /// + /// Selection-driven sync (mouse click / arrow keys) only runs when the timestamp control is + /// enabled — the entry point is gated on Preferences.TimestampControl, which defaults to + /// off. Users who time-sync files with it off see the explicit menu command work but selection + /// do nothing, which reads as a broken feature. Shown once per application run, at the moment + /// sync is switched on. + /// + private static bool _timeSyncSettingsHintShown; + + private void ShowTimeSyncSettingsHintOnce () + { + if (_timeSyncSettingsHintShown || Preferences.TimestampControl) + { + return; + } + + _timeSyncSettingsHintShown = true; + _ = MessageBox.Show(this, Resources.LogWindow_UI_TimeSync_TimestampControlHint, + Resources.LogExpert_Common_UI_Title_LogExpert, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + [SupportedOSPlatform("windows")] private void OnCopyToolStripMenuItemClick (object sender, EventArgs e) { From 852d599368ef63193a80ce173dd355d2e51d17d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 19:04:41 +0000 Subject: [PATCH 09/11] 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 17bef54c..538d3c93 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-21 07:10:59 UTC + /// Generated: 2026-07-24 19:04:39 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "B17B198624164070272EC6A7A82A5918FFD1A2B504E9740CBFED121D0BC3D507", + ["AutoColumnizer.dll"] = "799788C7420733D7CB616001256C94C3CE5935088554A9FB899AA6AB6B80CCA9", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "4DB94E7C49BB17CAC79EF6F961C6BF99BA3C4EE74876651813C84F0680981982", - ["CsvColumnizer.dll (x86)"] = "4DB94E7C49BB17CAC79EF6F961C6BF99BA3C4EE74876651813C84F0680981982", - ["DefaultPlugins.dll"] = "7AD4E853A0766282F07C6F6DC544F106724E92A52A862A4DDF0792FB46125D0C", - ["FlashIconHighlighter.dll"] = "3FB02733964F27E4D18D2DE12D02B457955435B6E0C76B23DBB5DE8DC1D7D7A2", - ["GlassfishColumnizer.dll"] = "32C93E8FD8DF6BA554E7BE1BCC3D0624F5160C76FCD1C79273DC8640FF88F6DA", - ["JsonColumnizer.dll"] = "3D1DC959F6BBD5280D040E3E36FA9480D0BC5E866141AEF3EC37BF70592F3206", - ["JsonCompactColumnizer.dll"] = "A9828BF37C33ED4F5C15844717AA4EDF3A0C77FE880304B1D48505A0F53D330E", - ["Log4jXmlColumnizer.dll"] = "C255335F357EC4FC4646C916536A76E383988C6CDD6D15259522ECA04CA7961E", - ["LogExpert.Resources.dll"] = "19249A47B49B69BAFC0837744A6519784604051352ED963212684F26728714CD", + ["CsvColumnizer.dll"] = "1A43B7E2A8DA011F09804A201A30312B6552EDB92477FD5852B381DC12C1462E", + ["CsvColumnizer.dll (x86)"] = "1A43B7E2A8DA011F09804A201A30312B6552EDB92477FD5852B381DC12C1462E", + ["DefaultPlugins.dll"] = "B5669C9583451CDA4636626E36CF3B20203AC30CE3EF37326BE7F162D0EA3D95", + ["FlashIconHighlighter.dll"] = "662BDB992E1FDB84FDC83251DE70F3408A7C922C2091C26ED80CAE103582A46D", + ["GlassfishColumnizer.dll"] = "543228D4D4EA7A424F8793E85F8587AD4A524CFC3C206F1E3FE76777098B3FBE", + ["JsonColumnizer.dll"] = "51924001993E6C142EBCEEF77DFF09D52779EB81B32634918934B0629B50C5C6", + ["JsonCompactColumnizer.dll"] = "A0231968C5208225371DD2C1B977DBB5DA01725228A0A195D5F4063E838BAB3A", + ["Log4jXmlColumnizer.dll"] = "54B3398F5873DB9EA7A09D971A4D5B0D52B4A8EE13308885F57CE986873E8E43", + ["LogExpert.Resources.dll"] = "57A373B176D942998D7748100A3C7E7B52AFE252E31227395ECCA6F3CDDCA24E", ["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"] = "00DF907ABDC3DD3C8554F076AB2F516C4F678DF4EEB25DC932CDED8036AB0052", - ["SftpFileSystem.dll"] = "7D15270F2F424BA50D459CFA33F83ED7D36A285108F16AA986F0DEB18935D7F4", - ["SftpFileSystem.dll (x86)"] = "B32E30684059632900694D536EDCF755FE5D0544BB95DF5B013629DA1AA751A8", - ["SftpFileSystem.Resources.dll"] = "19BB05F8897E4C1FA4EF4B491FEEC0C50105A8EB589F0033DB75AD6242EDA1C8", - ["SftpFileSystem.Resources.dll (x86)"] = "19BB05F8897E4C1FA4EF4B491FEEC0C50105A8EB589F0033DB75AD6242EDA1C8", + ["RegexColumnizer.dll"] = "275A80209803B9902B9815A03E6DF86F4D66999AF7343003D7F89282955F7D6F", + ["SftpFileSystem.dll"] = "D05DEC81BA61A86B411340ACFC7B1D7BB386AC977ACC2CFA0EC83A82AE572366", + ["SftpFileSystem.dll (x86)"] = "534C8DDD4E5884FA8966C4D287B99043E52069E10101ADAB640C5E4759FA8F6F", + ["SftpFileSystem.Resources.dll"] = "B9E6991EDCEE3C21D0ED563C24FEA22FC2D9238EDF010F7F5F62E9EA329EC059", + ["SftpFileSystem.Resources.dll (x86)"] = "B9E6991EDCEE3C21D0ED563C24FEA22FC2D9238EDF010F7F5F62E9EA329EC059", }; } From 4ed354456f616ef600e61fc6d333b62dc92db87c Mon Sep 17 00:00:00 2001 From: Hirogen Date: Fri, 24 Jul 2026 21:27:37 +0200 Subject: [PATCH 10/11] refactor: field-backed properties in TimeSpreadCalculator, per-window sync hint TimeSpreadCalculator's Enabled/TimeMode/Contrast drop their explicit backing fields for C# field-backed properties (matching the CurrentColumnizer idiom in LogWindow); initializers and the Contrast clamping are unchanged. Three FindForward line-number write-backs that were dead even in the pre-extraction ref-int code are now explicit discards; the two live write-backs in DoCalc's preamble stay. The time-sync settings hint flag becomes an instance field: shown once per window instead of once per application run, so a user who dismissed it in one window still gets it when enabling sync from another. --- .../Controls/LogWindow/LogWindow.cs | 6 +-- .../LogWindow/TimeSpreadCalculator.cs | 44 ++++++++----------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index ed31a340..ec9c7898 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -1616,10 +1616,10 @@ private void OnHandleSyncContextMenu (object sender, EventArgs args) /// Selection-driven sync (mouse click / arrow keys) only runs when the timestamp control is /// enabled — the entry point is gated on Preferences.TimestampControl, which defaults to /// off. Users who time-sync files with it off see the explicit menu command work but selection - /// do nothing, which reads as a broken feature. Shown once per application run, at the moment - /// sync is switched on. + /// do nothing, which reads as a broken feature. Shown at most once per window, at the moment + /// sync is switched on from that window's context menu. /// - private static bool _timeSyncSettingsHintShown; + private bool _timeSyncSettingsHintShown; private void ShowTimeSyncSettingsHintOnce () { diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs index 55dcb354..51423bce 100644 --- a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs +++ b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs @@ -21,13 +21,7 @@ internal class TimeSpreadCalculator // for DoCalc_via_Time private double _average; - - private int _contrast = 400; - private int _displayHeight; - - private bool _enabled; - private DateTime _endTimestamp; private int _lineCount; private int _maxDiff; @@ -35,8 +29,6 @@ internal class TimeSpreadCalculator private readonly CancellationTokenSource _cts = new(); private DateTime _startTimestamp; - private bool _timeMode = true; - // for DoCalc private int _timePerLine; @@ -65,11 +57,11 @@ public TimeSpreadCalculator (TimestampLocator locator, ITimestampSource source) public bool Enabled { - get => _enabled; + get; set { - _enabled = value; - if (_enabled) + field = value; + if (field) { _ = _calcEvent.Set(); _ = _lineCountEvent.Set(); @@ -79,30 +71,30 @@ public bool Enabled public bool TimeMode { - get => _timeMode; + get; set { - _timeMode = value; - if (_enabled) + field = value; + if (Enabled) { _ = _calcEvent.Set(); _ = _lineCountEvent.Set(); } } - } + } = true; public int Contrast { set { - _contrast = value; - if (_contrast < 0) + field = value; + if (field < 0) { - _contrast = 0; + field = 0; } - else if (_contrast > MAX_CONTRAST) + else if (field > MAX_CONTRAST) { - _contrast = MAX_CONTRAST; + field = MAX_CONTRAST; } if (TimeMode) @@ -117,8 +109,8 @@ public int Contrast OnCalcDone(EventArgs.Empty); } - get => _contrast; - } + get; + } = 400; public List DiffList { get; set; } = []; @@ -230,7 +222,7 @@ private void DoCalc () for (var i = lineNum; i < lastLineNum; i += step) { var currLineNum = i; - (var time, currLineNum) = _locator.FindForward(currLineNum, lineCount, false); + (var time, _) = _locator.FindForward(currLineNum, lineCount, false); if (time != DateTime.MinValue) { var span = time - oldTime; @@ -271,7 +263,7 @@ private void DoCalcViaTime () var lineNum = 0; var lastLineNum = lineCount - 1; - (_startTimestamp, lineNum) = _locator.FindForward(lineNum, lineCount, false); + (_startTimestamp, _) = _locator.FindForward(lineNum, lineCount, false); (_endTimestamp, lastLineNum) = _locator.FindBackward(lastLineNum, lineCount, false); if (_startTimestamp != DateTime.MinValue && _endTimestamp != DateTime.MinValue) @@ -378,7 +370,7 @@ private DateTime CalcValuesViaLines (int timePerLine) diffFromAverage = 0; } - var value = (int)(diffFromAverage / (timePerLine / TimeSpan.TicksPerMillisecond) * _contrast); + var value = (int)(diffFromAverage / (timePerLine / TimeSpan.TicksPerMillisecond) * Contrast); entry.Value = 255 - value; oldTime = entry.Timestamp; } @@ -399,7 +391,7 @@ private void CalcValuesViaTime (int maxDiff, double average) diffFromAverage = 0; } - var value = (int)(diffFromAverage / maxDiff * _contrast); + var value = (int)(diffFromAverage / maxDiff * Contrast); entry.Value = 255 - value; //var timestamp = $"{entry.Timestamp:HH:mm:ss.fff}"; From cd89eb11f79fef3d7d81f4f385b11d1a979120ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 19:30:25 +0000 Subject: [PATCH 11/11] 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 538d3c93..32590b5e 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-24 19:04:39 UTC + /// Generated: 2026-07-24 19:30:24 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "799788C7420733D7CB616001256C94C3CE5935088554A9FB899AA6AB6B80CCA9", + ["AutoColumnizer.dll"] = "FE3873F7C0C9800E98B4F5A37F30A8C7DA06E1B37CCC49513838701E6A2EADCD", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "1A43B7E2A8DA011F09804A201A30312B6552EDB92477FD5852B381DC12C1462E", - ["CsvColumnizer.dll (x86)"] = "1A43B7E2A8DA011F09804A201A30312B6552EDB92477FD5852B381DC12C1462E", - ["DefaultPlugins.dll"] = "B5669C9583451CDA4636626E36CF3B20203AC30CE3EF37326BE7F162D0EA3D95", - ["FlashIconHighlighter.dll"] = "662BDB992E1FDB84FDC83251DE70F3408A7C922C2091C26ED80CAE103582A46D", - ["GlassfishColumnizer.dll"] = "543228D4D4EA7A424F8793E85F8587AD4A524CFC3C206F1E3FE76777098B3FBE", - ["JsonColumnizer.dll"] = "51924001993E6C142EBCEEF77DFF09D52779EB81B32634918934B0629B50C5C6", - ["JsonCompactColumnizer.dll"] = "A0231968C5208225371DD2C1B977DBB5DA01725228A0A195D5F4063E838BAB3A", - ["Log4jXmlColumnizer.dll"] = "54B3398F5873DB9EA7A09D971A4D5B0D52B4A8EE13308885F57CE986873E8E43", - ["LogExpert.Resources.dll"] = "57A373B176D942998D7748100A3C7E7B52AFE252E31227395ECCA6F3CDDCA24E", + ["CsvColumnizer.dll"] = "ED495D3B7B80DEC9CC727A13BD945D99947465FDFAB3345A0AD1943E15ECB7AC", + ["CsvColumnizer.dll (x86)"] = "ED495D3B7B80DEC9CC727A13BD945D99947465FDFAB3345A0AD1943E15ECB7AC", + ["DefaultPlugins.dll"] = "15CF0336CC20FBA8706FC062C1E6428D141206720B60E3C36ABFD7DA36BA1A18", + ["FlashIconHighlighter.dll"] = "5812D75FFF759D551BD1E3141BE6C1665A1CA6E46942D0F7107F5029C9C8C9BF", + ["GlassfishColumnizer.dll"] = "50852BAA90A21839F64162CA2195F59E42C936D953DA25C21A960789ACFDBE9B", + ["JsonColumnizer.dll"] = "72B503081488BA4726A4F45D344BCF24C136F37EF9F2429383AF4C22E18B18B9", + ["JsonCompactColumnizer.dll"] = "5D3BC4B0D894969A56F6C02B84A3E033E73F42071E91E320E0661DB0C57C3531", + ["Log4jXmlColumnizer.dll"] = "F51C4422B8033B567E311E728A636AFA0BB26A0D2875AD162E1B91BECFE67BE9", + ["LogExpert.Resources.dll"] = "1556B4D260339BA4B2A77CFF2ADD72CD460466BBF88E09DF05E2622911403FA2", ["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"] = "275A80209803B9902B9815A03E6DF86F4D66999AF7343003D7F89282955F7D6F", - ["SftpFileSystem.dll"] = "D05DEC81BA61A86B411340ACFC7B1D7BB386AC977ACC2CFA0EC83A82AE572366", - ["SftpFileSystem.dll (x86)"] = "534C8DDD4E5884FA8966C4D287B99043E52069E10101ADAB640C5E4759FA8F6F", - ["SftpFileSystem.Resources.dll"] = "B9E6991EDCEE3C21D0ED563C24FEA22FC2D9238EDF010F7F5F62E9EA329EC059", - ["SftpFileSystem.Resources.dll (x86)"] = "B9E6991EDCEE3C21D0ED563C24FEA22FC2D9238EDF010F7F5F62E9EA329EC059", + ["RegexColumnizer.dll"] = "1F9494C1BDE818161EC4A2A5854A02E18584D81C8188C7FAFB2C8A5C98A80EAE", + ["SftpFileSystem.dll"] = "0C2CAD2C2F5A71179E63768BA59EA8462620C3C500EAE027AF8DBF5B61B9CE64", + ["SftpFileSystem.dll (x86)"] = "D35C79C0875ADBE0B06ACA0C6DA4584F901E1B053D64E5FA322CA8CE4C087D87", + ["SftpFileSystem.Resources.dll"] = "130EE63C367D1485FB81568224FDFA2501E1EB2BD6ED2C4B1D505260CBD34E47", + ["SftpFileSystem.Resources.dll (x86)"] = "130EE63C367D1485FB81568224FDFA2501E1EB2BD6ED2C4B1D505260CBD34E47", }; }