diff --git a/CONTEXT.md b/CONTEXT.md index ae086aa2b..b4e1eaad6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -165,6 +165,47 @@ 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 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" / +"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 diff --git a/src/LogExpert.Core/Callback/ColumnizerCallback.cs b/src/LogExpert.Core/Callback/ColumnizerCallback.cs index c2e94652c..813a04c31 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/Classes/Timestamp/ITimestampSource.cs b/src/LogExpert.Core/Classes/Timestamp/ITimestampSource.cs new file mode 100644 index 000000000..4f5b571a4 --- /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 000000000..01c8255d2 --- /dev/null +++ b/src/LogExpert.Core/Classes/Timestamp/TimestampLocator.cs @@ -0,0 +1,220 @@ +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 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) + { + 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/ILogWindow.cs b/src/LogExpert.Core/Interfaces/ILogWindow.cs index 7cc3afb7d..cc7ae5af1 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.Core/Interfaces/IPositionedColumnizerCallback.cs b/src/LogExpert.Core/Interfaces/IPositionedColumnizerCallback.cs new file mode 100644 index 000000000..e32b682c0 --- /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.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 9dcec2fe9..830b727ae 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 1d0761724..75ce0941d 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 b5e64674c..1d6d3c857 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.Tests/Timestamp/TimestampLocatorTests.cs b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs new file mode 100644 index 000000000..5aa58c886 --- /dev/null +++ b/src/LogExpert.Tests/Timestamp/TimestampLocatorTests.cs @@ -0,0 +1,738 @@ +using System.Globalization; +using System.Linq; +using System.Threading; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Columnizer; +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_NotRoundToSeconds_PreservesTheMillisecondComponent () + { + 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(); + + 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)); + }); + readerMock.Verify(r => r.GetLogLineMemory(It.IsAny()), Times.Never); + } + + [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_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 () + { + 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_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 () + { + 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): 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 () + { + 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)); + } + + /// + /// 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_ReturnsTheNearestLineAsAPositiveNumber () + { + 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); + + // 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_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); + + // 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] + 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 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)); + + /// + /// 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 +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index ae3a2131b..ec9c7898d 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; } @@ -1593,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 at most once per window, at the moment + /// sync is switched on from that window's context menu. + /// + private 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) { @@ -3952,7 +3988,6 @@ private void SyncTimestampDisplayWorker () { Thread.CurrentThread.Name = "SyncTimestampDisplayWorker"; _shouldTimestampDisplaySyncingCancel = false; - _isTimestampDisplaySyncing = true; while (!_shouldTimestampDisplaySyncingCancel) { @@ -5292,10 +5327,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(); } @@ -5581,7 +5614,7 @@ private void SyncOtherWindows (DateTime timestamp) { lock (_timeSyncListLock) { - TimeSyncList?.NavigateToTimestamp(timestamp, this); + TimeSyncList?.NavigateToTimestamp(timestamp, this, _logWindowCoordinator.IndicateTimeSyncActivity); } } @@ -7493,7 +7526,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, _cts.Token); if (foundLine >= 0) { SelectAndEnsureVisible(foundLine, triggerSyncCall); @@ -7504,189 +7537,23 @@ 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) - { - 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; - } + /// + /// Gets the timestamp for the line at or before , searching + /// 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, _cts.Token); } public void AppFocusLost () diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadCalculator.cs index 6af054a5b..51423bceb 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,23 +12,16 @@ 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; - - private int _contrast = 400; - private int _displayHeight; - - private bool _enabled; - private DateTime _endTimestamp; private int _lineCount; private int _maxDiff; @@ -37,8 +29,6 @@ internal class TimeSpreadCalculator private readonly CancellationTokenSource _cts = new(); private DateTime _startTimestamp; - private bool _timeMode = true; - // for DoCalc private int _timePerLine; @@ -46,10 +36,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); } @@ -67,11 +57,11 @@ public TimeSpreadCalculator (ILogWindow logWindow) public bool Enabled { - get => _enabled; + get; set { - _enabled = value; - if (_enabled) + field = value; + if (field) { _ = _calcEvent.Set(); _ = _lineCountEvent.Set(); @@ -81,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) @@ -119,8 +109,8 @@ public int Contrast OnCalcDone(EventArgs.Empty); } - get => _contrast; - } + get; + } = 400; public List DiffList { get; set; } = []; @@ -199,16 +189,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 +208,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 +222,7 @@ private void DoCalc () for (var i = lineNum; i < lastLineNum; i += step) { var currLineNum = i; - var time = _logWindow.GetTimestampForLineForward(ref currLineNum, false); + (var time, _) = _locator.FindForward(currLineNum, lineCount, false); if (time != DateTime.MinValue) { var span = time - oldTime; @@ -262,7 +253,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 +262,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, _) = _locator.FindForward(lineNum, lineCount, false); + (_endTimestamp, lastLineNum) = _locator.FindBackward(lastLineNum, lineCount, false); if (_startTimestamp != DateTime.MinValue && _endTimestamp != DateTime.MinValue) { @@ -296,7 +288,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; @@ -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}"; diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSyncList.cs index 8faa997ec..a9c92032f 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 2e3801266..8299aef4e 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 c4a15d917..c87b0e8ae 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 = []; diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 17bef54c6..32590b5ed 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:30:24 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "B17B198624164070272EC6A7A82A5918FFD1A2B504E9740CBFED121D0BC3D507", + ["AutoColumnizer.dll"] = "FE3873F7C0C9800E98B4F5A37F30A8C7DA06E1B37CCC49513838701E6A2EADCD", ["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"] = "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"] = "00DF907ABDC3DD3C8554F076AB2F516C4F678DF4EEB25DC932CDED8036AB0052", - ["SftpFileSystem.dll"] = "7D15270F2F424BA50D459CFA33F83ED7D36A285108F16AA986F0DEB18935D7F4", - ["SftpFileSystem.dll (x86)"] = "B32E30684059632900694D536EDCF755FE5D0544BB95DF5B013629DA1AA751A8", - ["SftpFileSystem.Resources.dll"] = "19BB05F8897E4C1FA4EF4B491FEEC0C50105A8EB589F0033DB75AD6242EDA1C8", - ["SftpFileSystem.Resources.dll (x86)"] = "19BB05F8897E4C1FA4EF4B491FEEC0C50105A8EB589F0033DB75AD6242EDA1C8", + ["RegexColumnizer.dll"] = "1F9494C1BDE818161EC4A2A5854A02E18584D81C8188C7FAFB2C8A5C98A80EAE", + ["SftpFileSystem.dll"] = "0C2CAD2C2F5A71179E63768BA59EA8462620C3C500EAE027AF8DBF5B61B9CE64", + ["SftpFileSystem.dll (x86)"] = "D35C79C0875ADBE0B06ACA0C6DA4584F901E1B053D64E5FA322CA8CE4C087D87", + ["SftpFileSystem.Resources.dll"] = "130EE63C367D1485FB81568224FDFA2501E1EB2BD6ED2C4B1D505260CBD34E47", + ["SftpFileSystem.Resources.dll (x86)"] = "130EE63C367D1485FB81568224FDFA2501E1EB2BD6ED2C4B1D505260CBD34E47", }; }