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",
};
}