diff --git a/CONTEXT.md b/CONTEXT.md
index adbf054b..dde30b65 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -104,6 +104,23 @@ bare "session file" when you mean the workspace (that's a **Session**).
Spread**), "additional filter results" (the old internal name — use
**Filter Spread**).
+## Log Search
+
+- **Log Search** — The Ctrl+F / F3 / Shift+F3 search-in-file feature of a
+ Log Window: finds the next or previous line matching a search text
+ (plain or regex, case-sensitive or not), wrapping around the file
+ boundary once before giving up. Executed by
+ `LogExpert.Core.Classes.Search.LogSearcher`, the single owner of
+ direction resolution (forward / find-next / Shift+F3), the
+ wrap-around-once rule, and the matching; the Log Window narrates the
+ returned `SearchResult` (status line, progress bar, scrolling).
+ `Find` snapshots its `SearchParams` at entry, so mutating the shared
+ instance (F3) never affects a run in flight.
+
+*Avoid*: bare "search" when the filter panel is meant (that is
+**filtering** — the `FilterSearch` methods in the code belong to the
+filter path, not Log Search), "find dialog" (use **Search dialog**).
+
## Columnizer selection
- **Columnizer** (`ILogLineMemoryColumnizer`) — A plugin that parses a log
diff --git a/src/LogExpert.Core/Classes/Search/LogSearcher.cs b/src/LogExpert.Core/Classes/Search/LogSearcher.cs
new file mode 100644
index 00000000..b7b60f5a
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Search/LogSearcher.cs
@@ -0,0 +1,123 @@
+using System.Text.RegularExpressions;
+
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+
+namespace LogExpert.Core.Classes.Search;
+
+///
+/// Owner of Log Search execution: direction resolution, the wrap-around-once rule, and
+/// regex/ordinal matching over an .
+/// Pure — no dependency on controls or status lines; progress and the wrap event are
+/// reported through a progress sink, cancellation through a token.
+///
+public static class LogSearcher
+{
+ /// Lines scanned between two progress reports.
+ private const int PROGRESS_REPORT_MODULO = 1000;
+
+ ///
+ /// Resolves the effective direction of a search from the forward / find-next / Shift+F3 flags.
+ ///
+ public static SearchDirection ResolveDirection (SearchParams searchParams)
+ {
+ ArgumentNullException.ThrowIfNull(searchParams);
+
+ return (searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed
+ ? SearchDirection.Forward
+ : SearchDirection.Backward;
+ }
+
+ ///
+ /// Runs the search and returns how it ended. Never throws for user input errors —
+ /// an uncompilable regex yields .
+ ///
+ public static SearchResult Find (SearchParams searchParams, ILogfileReader reader, CancellationToken cancellationToken, IProgress? progress = null)
+ {
+ ArgumentNullException.ThrowIfNull(searchParams);
+ ArgumentNullException.ThrowIfNull(reader);
+
+ if (string.IsNullOrEmpty(searchParams.SearchText))
+ {
+ return new SearchResult(SearchOutcome.NotFound, -1, false);
+ }
+
+ // Snapshot: the caller may share this instance (F3 mutates it while a search runs).
+ var search = new SearchParams();
+ search.CopyFrom(searchParams);
+
+ var isForward = ResolveDirection(search) == SearchDirection.Forward;
+
+ var lineNum = search.IsFromTop && !search.IsFindNext
+ ? 0
+ : search.CurrentLine;
+
+ var comparison = search.IsCaseSensitive
+ ? StringComparison.Ordinal
+ : StringComparison.OrdinalIgnoreCase;
+
+ // Compiled once per search, not once per line; a bad pattern fails before any line is read.
+ Regex? regex = null;
+ if (search.IsRegex)
+ {
+ try
+ {
+ regex = new Regex(search.SearchText, search.IsCaseSensitive
+ ? RegexOptions.None
+ : RegexOptions.IgnoreCase);
+ }
+ catch (ArgumentException)
+ {
+ return new SearchResult(SearchOutcome.InvalidPattern, -1, false);
+ }
+ }
+
+ var hasWrapped = false;
+ var linesScanned = 0;
+
+ while (true)
+ {
+ if (isForward ? lineNum >= reader.LineCount : lineNum < 0)
+ {
+ if (hasWrapped)
+ {
+ return new SearchResult(SearchOutcome.NotFound, -1, true);
+ }
+
+ hasWrapped = true;
+ lineNum = isForward ? 0 : reader.LineCount - 1;
+ linesScanned = 0;
+ progress?.Report(new SearchProgress(0, isForward ? SearchWrap.ToStart : SearchWrap.ToEnd));
+ }
+
+ var line = reader.GetLogLineMemory(lineNum);
+ if (line == null)
+ {
+ return new SearchResult(SearchOutcome.NotFound, -1, hasWrapped);
+ }
+
+ var isMatch = regex != null
+ ? regex.IsMatch(line.FullLine.ToString())
+ : line.FullLine.Span.Contains(search.SearchText, comparison);
+
+ if (isMatch)
+ {
+ return new SearchResult(SearchOutcome.Found, lineNum, hasWrapped);
+ }
+
+ lineNum = isForward ? lineNum + 1 : lineNum - 1;
+
+ // Checked after the match test, matching the original loop: a search whose
+ // current line matches returns the hit even if cancellation was requested.
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return new SearchResult(SearchOutcome.Cancelled, -1, hasWrapped);
+ }
+
+ if (++linesScanned % PROGRESS_REPORT_MODULO == 0)
+ {
+ progress?.Report(new SearchProgress(linesScanned, SearchWrap.None));
+ }
+ }
+ }
+}
diff --git a/src/LogExpert.Core/Classes/Search/SearchDirection.cs b/src/LogExpert.Core/Classes/Search/SearchDirection.cs
new file mode 100644
index 00000000..f3282c22
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Search/SearchDirection.cs
@@ -0,0 +1,10 @@
+namespace LogExpert.Core.Classes.Search;
+
+///
+/// Effective direction of a Log Search after resolving the forward / find-next / Shift+F3 flags.
+///
+public enum SearchDirection
+{
+ Forward,
+ Backward,
+}
diff --git a/src/LogExpert.Core/Classes/Search/SearchOutcome.cs b/src/LogExpert.Core/Classes/Search/SearchOutcome.cs
new file mode 100644
index 00000000..9fa63004
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Search/SearchOutcome.cs
@@ -0,0 +1,19 @@
+namespace LogExpert.Core.Classes.Search;
+
+///
+/// How a Log Search run ended.
+///
+public enum SearchOutcome
+{
+ /// A matching line was found.
+ Found,
+
+ /// No matching line exists (including after a full wrap-around), or the search text was empty.
+ NotFound,
+
+ /// The search was cancelled before a match was found.
+ Cancelled,
+
+ /// The search text was a regular expression that does not compile. No line was read.
+ InvalidPattern,
+}
diff --git a/src/LogExpert.Core/Classes/Search/SearchProgress.cs b/src/LogExpert.Core/Classes/Search/SearchProgress.cs
new file mode 100644
index 00000000..48a1f967
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Search/SearchProgress.cs
@@ -0,0 +1,9 @@
+namespace LogExpert.Core.Classes.Search;
+
+///
+/// Progress report from a running Log Search.
+///
+/// Lines scanned in the current segment (resets when the search wraps),
+/// suitable for driving a progress bar whose maximum is the line count.
+/// The wrap event, when this report announces one.
+public readonly record struct SearchProgress (int LinesScanned, SearchWrap Wrap);
diff --git a/src/LogExpert.Core/Classes/Search/SearchResult.cs b/src/LogExpert.Core/Classes/Search/SearchResult.cs
new file mode 100644
index 00000000..cfe8116b
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Search/SearchResult.cs
@@ -0,0 +1,10 @@
+namespace LogExpert.Core.Classes.Search;
+
+///
+/// Result of a Log Search run. The caller narrates the outcome (status line, scrolling);
+/// the searcher never touches UI.
+///
+/// How the search ended.
+/// Zero-based hit line when ; -1 otherwise.
+/// Whether the search wrapped around the file boundary before ending.
+public readonly record struct SearchResult (SearchOutcome Outcome, int LineNumber, bool Wrapped);
diff --git a/src/LogExpert.Core/Classes/Search/SearchWrap.cs b/src/LogExpert.Core/Classes/Search/SearchWrap.cs
new file mode 100644
index 00000000..a98698a6
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Search/SearchWrap.cs
@@ -0,0 +1,17 @@
+namespace LogExpert.Core.Classes.Search;
+
+///
+/// Wrap event reported through the progress sink, so the caller can show the
+/// "started from beginning/end of file" notice while the search is still running.
+///
+public enum SearchWrap
+{
+ /// Ordinary progress report; no wrap occurred.
+ None,
+
+ /// A forward search passed the last line and continued from line 0.
+ ToStart,
+
+ /// A backward search passed line 0 and continued from the last line.
+ ToEnd,
+}
diff --git a/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs b/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs
new file mode 100644
index 00000000..45d4df41
--- /dev/null
+++ b/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs
@@ -0,0 +1,67 @@
+using LogExpert.UI.Dialogs;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Dialogs;
+
+///
+/// Regression tests for the Regex Helper dialog's OK handler. Both combo boxes are
+/// DataSource-bound to the history lists (LoadHistory, run on dialog Load), so the
+/// handler must never touch ComboBox.Items — doing so throws
+/// "Items collection cannot be modified when the DataSource property is set".
+///
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+public class RegexHelperDialogTests
+{
+ [Test]
+ public void OkClick_AfterLoadBindsHistory_DoesNotThrowAndPromotesPattern ()
+ {
+ using RegexHelperDialog dialog = new()
+ {
+ ExpressionHistoryList = ["old-a", "old-b"],
+ TesttextHistoryList = ["text-a"],
+ };
+ dialog.LoadHistory(); // what dialog Load does when the form is shown
+ dialog.Pattern = "new-pattern";
+
+ dialog.OnButtonOkClick(dialog, EventArgs.Empty);
+
+ Assert.That(dialog.ExpressionHistoryList, Is.EqualTo(new[] { "new-pattern", "old-a", "old-b" }));
+ }
+
+ [Test]
+ public void OkClick_PatternAlreadyInHistory_MovesItToTheFrontWithoutDuplicate ()
+ {
+ using RegexHelperDialog dialog = new()
+ {
+ ExpressionHistoryList = ["old-a", "repeat", "old-b"],
+ };
+ dialog.LoadHistory();
+ dialog.Pattern = "repeat";
+
+ dialog.OnButtonOkClick(dialog, EventArgs.Empty);
+
+ Assert.That(dialog.ExpressionHistoryList, Is.EqualTo(new[] { "repeat", "old-a", "old-b" }));
+ }
+
+ [Test]
+ public void OkClick_HistoryAtCapacity_DropsTheOldestEntry ()
+ {
+ using RegexHelperDialog dialog = new()
+ {
+ ExpressionHistoryList = [.. Enumerable.Range(0, 30).Select(i => $"entry-{i}")],
+ };
+ dialog.LoadHistory();
+ dialog.Pattern = "newest";
+
+ dialog.OnButtonOkClick(dialog, EventArgs.Empty);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(dialog.ExpressionHistoryList, Has.Count.EqualTo(30));
+ Assert.That(dialog.ExpressionHistoryList[0], Is.EqualTo("newest"));
+ Assert.That(dialog.ExpressionHistoryList, Does.Not.Contain("entry-29"));
+ });
+ }
+}
diff --git a/src/LogExpert.Tests/Search/LogSearcherTests.cs b/src/LogExpert.Tests/Search/LogSearcherTests.cs
new file mode 100644
index 00000000..db2ef527
--- /dev/null
+++ b/src/LogExpert.Tests/Search/LogSearcherTests.cs
@@ -0,0 +1,398 @@
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Search;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+
+using Moq;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Search;
+
+[TestFixture]
+public class LogSearcherTests
+{
+ [Test]
+ public void Find_ForwardFromStart_ReturnsFirstHitAfterCurrentLine ()
+ {
+ var reader = ReaderOf("alpha", "bravo", "target", "delta", "target");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false)));
+ }
+
+ // Truth table for the effective-direction rule the Log Window triplicated:
+ // forward when (IsForward || IsFindNext), reversed by Shift+F3. Pins today's semantics,
+ // including the quirk that F3 (find next) on a backward search goes forward.
+ [TestCase(true, false, false, SearchDirection.Forward, TestName = "ResolveDirection_Forward_IsForward")]
+ [TestCase(true, true, false, SearchDirection.Forward, TestName = "ResolveDirection_Forward_IsForwardAndFindNext")]
+ [TestCase(false, true, false, SearchDirection.Forward, TestName = "ResolveDirection_Forward_FindNextOverridesBackward")]
+ [TestCase(false, false, false, SearchDirection.Backward, TestName = "ResolveDirection_Backward_NeitherForwardNorFindNext")]
+ [TestCase(true, false, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3ReversesForward")]
+ [TestCase(true, true, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3ReversesFindNext")]
+ [TestCase(false, true, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3ReversesBackwardFindNext")]
+ [TestCase(false, false, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3OnBackwardStaysBackward")]
+ public void ResolveDirection_Table (bool isForward, bool isFindNext, bool isShiftF3Pressed, SearchDirection expected)
+ {
+ var searchParams = new SearchParams { IsForward = isForward, IsFindNext = isFindNext, IsShiftF3Pressed = isShiftF3Pressed };
+
+ Assert.That(LogSearcher.ResolveDirection(searchParams), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Find_Backward_ReturnsNearestHitBeforeCurrentLine ()
+ {
+ var reader = ReaderOf("target", "bravo", "target", "delta", "echo");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = false, CurrentLine = 4 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_ForwardPastEndOfFile_WrapsToStartAndFindsEarlierHit ()
+ {
+ var reader = ReaderOf("alpha", "target", "bravo", "charlie");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 2 };
+ var progress = new ProgressRecorder();
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: true)));
+ Assert.That(progress.Reports.Select(r => r.Wrap), Does.Contain(SearchWrap.ToStart),
+ "the wrap event must reach the progress sink so the control can show the notice mid-search");
+ });
+ }
+
+ [Test]
+ public void Find_BackwardPastTopOfFile_WrapsToEndAndFindsLaterHit ()
+ {
+ var reader = ReaderOf("alpha", "bravo", "charlie", "target");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = false, CurrentLine = 1 };
+ var progress = new ProgressRecorder();
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 3, Wrapped: true)));
+ Assert.That(progress.Reports.Select(r => r.Wrap), Does.Contain(SearchWrap.ToEnd));
+ });
+ }
+
+ [Test]
+ public void Find_WrappedFullCircleWithoutMatch_ReturnsNotFoundWrapped ()
+ {
+ var reader = ReaderOf("alpha", "bravo", "charlie");
+ var searchParams = new SearchParams { SearchText = "missing", IsForward = true, CurrentLine = 1 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.NotFound, -1, Wrapped: true)));
+ }
+
+ [Test]
+ public void Find_BackwardWrappedFullCircleWithoutMatch_ReturnsNotFoundWrapped ()
+ {
+ var reader = ReaderOf("alpha", "bravo", "charlie");
+ var searchParams = new SearchParams { SearchText = "missing", IsForward = false, CurrentLine = 1 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.NotFound, -1, Wrapped: true)));
+ }
+
+ [Test]
+ public void Find_StartPositionPastEndOfFile_WrapsImmediatelyAndScansFromTop ()
+ {
+ var reader = ReaderOf("target", "alpha", "bravo");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 99 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: true)));
+ }
+
+ [Test]
+ public void Find_WrapEvent_IsReportedBeforeThePostWrapHitIsReturned ()
+ {
+ var reader = ReaderOf("target", "alpha", "bravo");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 1 };
+ var scannedAtWrap = -1;
+ var progress = new ProgressRecorder();
+ progress.OnWrap = () => scannedAtWrap = progress.Reports.Count;
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.Found));
+ Assert.That(scannedAtWrap, Is.GreaterThanOrEqualTo(0), "wrap must be reported during the run, not inferred afterwards");
+ });
+ }
+
+ [Test]
+ public void Find_CaseInsensitive_MatchesDifferentCasing ()
+ {
+ var reader = ReaderOf("alpha", "some Target here");
+ var searchParams = new SearchParams { SearchText = "TARGET", IsForward = true, CurrentLine = 0, IsCaseSensitive = false };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_CaseSensitive_SkipsWrongCasingAndFindsExactMatch ()
+ {
+ var reader = ReaderOf("target wrong case: ERROR", "exact: error");
+ var searchParams = new SearchParams { SearchText = "error", IsForward = true, CurrentLine = 0, IsCaseSensitive = true };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_FromTop_StartsAtLineZeroRegardlessOfCurrentLine ()
+ {
+ var reader = ReaderOf("target", "alpha", "bravo", "charlie");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, IsFromTop = true, CurrentLine = 3 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false)),
+ "from-top must scan from line 0 without needing to wrap");
+ }
+
+ [Test]
+ public void Find_FromTopWithFindNext_ContinuesFromCurrentLineInstead ()
+ {
+ // Today's rule: F3 (find next) overrides the from-top start so repeat-search advances.
+ var reader = ReaderOf("target", "alpha", "target", "charlie");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, IsFromTop = true, IsFindNext = true, CurrentLine = 1 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_HitOnLastLine_IsFound ()
+ {
+ var reader = ReaderOf("alpha", "bravo", "target");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_HitOnLineZeroBackward_IsFound ()
+ {
+ var reader = ReaderOf("target", "alpha", "bravo");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = false, CurrentLine = 2 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_Regex_MatchesByPattern ()
+ {
+ var reader = ReaderOf("alpha", "code err42 raised", "bravo");
+ var searchParams = new SearchParams { SearchText = @"err\d+", IsRegex = true, IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_CaseSensitiveRegex_SkipsWrongCasing ()
+ {
+ var reader = ReaderOf("ERR1", "err2");
+ var searchParams = new SearchParams { SearchText = @"err\d", IsRegex = true, IsCaseSensitive = true, IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_CaseInsensitiveRegex_MatchesWrongCasing ()
+ {
+ var reader = ReaderOf("ERR1", "err2");
+ var searchParams = new SearchParams { SearchText = @"err\d", IsRegex = true, IsCaseSensitive = false, IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_InvalidRegex_ReturnsInvalidPatternWithoutReadingAnyLine ()
+ {
+ var readerMock = ReaderMockOf("alpha", "bravo");
+ var searchParams = new SearchParams { SearchText = "(unclosed", IsRegex = true, IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, readerMock.Object, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.InvalidPattern, -1, Wrapped: false)));
+ readerMock.Verify(r => r.GetLogLineMemory(It.IsAny()), Times.Never,
+ "an uncompilable pattern must fail before any line is read");
+ }
+
+ [Test]
+ public void Find_ShiftF3OnFindNext_ScansBackwardThroughTheSeam ()
+ {
+ // Forward would find line 3; Shift+F3 must reverse the find-next and reach line 0.
+ var reader = ReaderOf("target", "alpha", "bravo", "target");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, IsFindNext = true, IsShiftF3Pressed = true, CurrentLine = 2 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_CancelledTokenWithMatchOnCurrentLine_StillReturnsTheHit ()
+ {
+ // Pins the original loop's ordering: the match test runs before the cancel check.
+ var reader = ReaderOf("target", "alpha");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 };
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var result = LogSearcher.Find(searchParams, reader, cts.Token);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_CancelledToken_ReturnsCancelled ()
+ {
+ var reader = ReaderOf("alpha", "bravo", "target");
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 };
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var result = LogSearcher.Find(searchParams, reader, cts.Token);
+
+ Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.Cancelled));
+ }
+
+ [TestCase(null, TestName = "Find_NullSearchText_ReturnsNotFound")]
+ [TestCase("", TestName = "Find_EmptySearchText_ReturnsNotFound")]
+ public void Find_MissingSearchText_ReturnsNotFound (string? searchText)
+ {
+ var reader = ReaderOf("alpha", "bravo");
+ var searchParams = new SearchParams { SearchText = searchText!, IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.NotFound, -1, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_ReaderReturnsNullLine_EndsAsNotFound ()
+ {
+ // The reader contract: null for unavailable lines (deleted file, timeout). Never throw.
+ var readerMock = new Mock();
+ _ = readerMock.Setup(r => r.LineCount).Returns(5);
+ _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())).Returns((ILogLineMemory)null!);
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 };
+
+ var result = LogSearcher.Find(searchParams, readerMock.Object, CancellationToken.None);
+
+ Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.NotFound));
+ }
+
+ [Test]
+ public void Find_MutatingSearchParamsMidRun_DoesNotAffectTheRunningSearch ()
+ {
+ // The shared coordinator SearchParams is mutated by F3 while a search runs; Find
+ // must operate on a snapshot taken at entry.
+ var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 };
+
+ var mock = new Mock();
+ _ = mock.Setup(r => r.LineCount).Returns(3);
+ _ = mock.Setup(r => r.GetLogLineMemory(It.IsAny()))
+ .Returns((int lineNum) =>
+ {
+ searchParams.SearchText = "changed-mid-run";
+ searchParams.IsForward = false;
+ return LineOf(lineNum == 2 ? "target" : "alpha");
+ });
+
+ var result = LogSearcher.Find(searchParams, mock.Object, CancellationToken.None);
+
+ Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false)));
+ }
+
+ [Test]
+ public void Find_LongScan_ReportsProgressEveryThousandLinesAndResetsOnWrap ()
+ {
+ var lines = Enumerable.Repeat("filler", 2500).ToArray();
+ var reader = ReaderOf(lines);
+ var searchParams = new SearchParams { SearchText = "missing", IsForward = true, CurrentLine = 0 };
+ var progress = new ProgressRecorder();
+
+ var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress);
+
+ var scanReports = progress.Reports.Where(r => r.Wrap == SearchWrap.None).Select(r => r.LinesScanned);
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.NotFound));
+ // Segment counter resets on wrap, matching a progress bar whose maximum is the line count.
+ Assert.That(scanReports, Is.EqualTo(new[] { 1000, 2000, 1000, 2000 }));
+ });
+ }
+
+ #region Fake reader over known lines
+
+ private static ILogfileReader ReaderOf (params string[] lines) => ReaderMockOf(lines).Object;
+
+ private static Mock ReaderMockOf (params string[] lines)
+ {
+ var mock = new Mock();
+ _ = mock.Setup(r => r.LineCount).Returns(lines.Length);
+ _ = mock.Setup(r => r.GetLogLineMemory(It.IsAny()))
+ .Returns((int lineNum) => lineNum >= 0 && lineNum < lines.Length ? LineOf(lines[lineNum]) : null!);
+ return mock;
+ }
+
+ private static ILogLineMemory LineOf (string text)
+ {
+ var mock = new Mock();
+ _ = mock.Setup(l => l.FullLine).Returns(text.AsMemory());
+ return mock.Object;
+ }
+
+ /// Synchronous recorder — System.Progress posts asynchronously, which tests must not rely on.
+ private sealed class ProgressRecorder : IProgress
+ {
+ public List Reports { get; } = [];
+
+ public Action? OnWrap { get; set; }
+
+ public void Report (SearchProgress value)
+ {
+ Reports.Add(value);
+ if (value.Wrap != SearchWrap.None)
+ {
+ OnWrap?.Invoke();
+ }
+ }
+ }
+
+ #endregion
+}
diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
index a6dbbf71..9a831067 100644
--- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
@@ -17,6 +17,7 @@
using LogExpert.Core.Classes.Highlight;
using LogExpert.Core.Classes.Log;
using LogExpert.Core.Classes.Persister;
+using LogExpert.Core.Classes.Search;
using LogExpert.Core.Config;
using LogExpert.Core.Entities;
using LogExpert.Core.EventArguments;
@@ -120,6 +121,7 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL
private HighlightGroup _currentHighlightGroup = new();
private SearchParams _currentSearchParams;
+ private CancellationTokenSource? _searchCts;
private string[] _fileNames;
private List _filterHitList = [];
@@ -2707,6 +2709,7 @@ private void EnterLoadFileStatus ()
_isLoading = true;
_shouldCancel = true;
+ _searchCts?.Cancel();
ClearFilterList();
ClearBookmarkList();
dataGridView.ClearSelection();
@@ -2746,6 +2749,7 @@ private void LogfileDead ()
_statusEventArgs.CurrentLineNum = 0;
SendStatusLineUpdate();
_shouldCancel = true;
+ _searchCts?.Cancel();
ClearFilterList();
ClearBookmarkList();
@@ -4057,119 +4061,42 @@ private void StatusLineFileSize (long size)
SendStatusLineUpdate();
}
- [SupportedOSPlatform("windows")]
- private int Search (SearchParams searchParams)
+ ///
+ /// Narrates a running search's progress: wrap notices on the status line (mid-search,
+ /// like the old in-loop reporting) and scanned-line counts to the progress bar.
+ /// Called synchronously on the search's background thread.
+ ///
+ private void ReportSearchProgress (SearchProgress searchProgress)
{
- if (searchParams.SearchText == null)
+ switch (searchProgress.Wrap)
{
- return -1;
+ case SearchWrap.ToStart:
+ StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromBeginningOfFile);
+ return;
+ case SearchWrap.ToEnd:
+ StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromEndOfFile);
+ return;
}
- var lineNum = searchParams.IsFromTop && !searchParams.IsFindNext
- ? 0
- : searchParams.CurrentLine;
-
- var lowerSearchText = searchParams.SearchText.ToUpperInvariant();
- var count = 0;
- var hasWrapped = false;
-
- while (true)
+ try
{
- if ((searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed)
- {
- if (lineNum >= _logFileReader.LineCount)
- {
- if (hasWrapped)
- {
- StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_NotFound, searchParams.SearchText));
- return -1;
- }
-
- lineNum = 0;
- count = 0;
- hasWrapped = true;
- StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromBeginningOfFile);
- }
- }
- else
- {
- if (lineNum < 0)
- {
- if (hasWrapped)
- {
- StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_NotFound, searchParams.SearchText));
- return -1;
- }
-
- count = 0;
- lineNum = _logFileReader.LineCount - 1;
- hasWrapped = true;
- StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromEndOfFile);
- }
- }
-
- var line = _logFileReader.GetLogLineMemory(lineNum);
- if (line == null)
- {
- return -1;
- }
-
- if (searchParams.IsRegex)
- {
- Regex rex = new(searchParams.SearchText, searchParams.IsCaseSensitive
- ? RegexOptions.None
- : RegexOptions.IgnoreCase);
- if (rex.IsMatch(line.FullLine.ToString()))
- {
- return lineNum;
- }
- }
- else
+ if (!Disposing)
{
- if (searchParams.IsCaseSensitive)
- {
- if (line.FullLine.Span.Contains(searchParams.SearchText, StringComparison.Ordinal))
- {
- return lineNum;
- }
- }
- else
- {
- if (line.FullLine.Span.Contains(lowerSearchText, StringComparison.OrdinalIgnoreCase))
- {
- return lineNum;
- }
- }
- }
-
- if ((searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed)
- {
- lineNum++;
- }
- else
- {
- lineNum--;
- }
-
- if (_shouldCancel)
- {
- return -1;
+ _ = Invoke(UpdateProgressBar, [searchProgress.LinesScanned]);
}
+ }
+ catch (ObjectDisposedException ex) // can occur when closing the app while searching
+ {
+ _logger.Warn(ex);
+ }
+ }
- if (++count % PROGRESS_BAR_MODULO == 0)
- {
- try
- {
- if (!Disposing)
- {
- _ = Invoke(UpdateProgressBar, [count]);
- }
- }
- catch (ObjectDisposedException ex) // can occur when closing the app while searching
- {
- _logger.Warn(ex);
- }
- }
+ /// Relays reports synchronously — System.Progress would post them asynchronously.
+ private sealed class SynchronousProgress (Action handler) : IProgress
+ {
+ public void Report (T value)
+ {
+ handler(value);
}
}
@@ -4197,10 +4124,8 @@ private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll)
return;
}
- if (lineNum == -1)
+ if (lineNum < 0)
{
- // Hmm... is that experimental code from early days?
- _ = MessageBox.Show(this, Resources.LogWindow_UI_SelectLine_SearchResultNotFound, Resources.LogExpert_Common_UI_Title_LogExpert);
return;
}
@@ -6504,6 +6429,7 @@ public void CloseLogWindow ()
StopTimestampSyncThread();
StopLogEventWorkerThread();
_shouldCancel = true;
+ _searchCts?.Cancel();
if (_logFileReader != null)
{
@@ -6997,14 +6923,14 @@ public void StartSearch ()
GuiStateUpdate(this, _guiStateArgs);
var searchParams = _logWindowCoordinator.SearchParams;
- searchParams.CurrentLine = (searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed
+ searchParams.CurrentLine = LogSearcher.ResolveDirection(searchParams) == SearchDirection.Forward
? dataGridView.CurrentCellAddress.Y + 1
: dataGridView.CurrentCellAddress.Y - 1;
_currentSearchParams = searchParams; // remember for async "not found" messages
_isSearching = true;
- _shouldCancel = false;
+ _shouldCancel = false; // shared flag: SelectLine's cancel gate and the filter loops read it
StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel);
_progressEventArgs.MinValue = 0;
@@ -7013,13 +6939,26 @@ public void StartSearch ()
_progressEventArgs.Visible = true;
SendProgressBarUpdate();
- _ = Task.Run(() => Search(searchParams)).ContinueWith(SearchComplete, TaskScheduler.Default);
+ // Replace the token source only after a consumed cancellation (mirrors the old
+ // _shouldCancel = false reset); a still-running previous search keeps sharing it,
+ // so one ESC cancels every running search.
+ if (_searchCts == null || _searchCts.IsCancellationRequested)
+ {
+ _searchCts?.Dispose();
+ _searchCts = new CancellationTokenSource();
+ }
+
+ var cancellationToken = _searchCts.Token;
+ var progress = new SynchronousProgress(ReportSearchProgress);
+
+ _ = Task.Run(() => LogSearcher.Find(searchParams, _logFileReader, cancellationToken, progress), CancellationToken.None)
+ .ContinueWith(SearchComplete, TaskScheduler.Default);
RemoveAllSearchHighlightEntries();
AddSearchHitHighlightEntry(searchParams);
}
- private void SearchComplete (Task task)
+ private void SearchComplete (Task task)
{
if (Disposing)
{
@@ -7029,15 +6968,28 @@ private void SearchComplete (Task task)
try
{
_ = Invoke(new MethodInvoker(ResetProgressBar));
- var line = task.Result;
+ var result = task.Result;
_guiStateArgs.MenuEnabled = true;
GuiStateUpdate(this, _guiStateArgs);
- if (line == -1)
+
+ switch (result.Outcome)
{
- return;
- }
+ case SearchOutcome.Found:
+ _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), result.LineNumber, true);
+ break;
+
+ case SearchOutcome.NotFound:
+ StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_NotFound, _currentSearchParams.SearchText));
+ break;
- _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), line, true);
+ case SearchOutcome.InvalidPattern:
+ StatusLineError(Resources.LogWindow_UI_StatusLineError_InvalidRegularExpression);
+ break;
+
+ default: // Cancelled — clear the busy status
+ StatusLineText(string.Empty);
+ break;
+ }
}
catch (Exception ex) // in the case the windows is already destroyed
{
@@ -7106,7 +7058,8 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e)
{
if (_isSearching)
{
- _shouldCancel = true;
+ _shouldCancel = true; // shared flag: filter loops and SelectLine's cancel gate read it
+ _searchCts?.Cancel();
}
CancelHighlightBookmarkScan();
diff --git a/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs b/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs
index b9b265f0..fa9114ba 100644
--- a/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs
+++ b/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs
@@ -107,7 +107,7 @@ private void UpdateMatches ()
}
}
- private void LoadHistory ()
+ internal void LoadHistory ()
{
comboBoxRegex.Items.Clear();
comboBoxRegex.DataSource = ExpressionHistoryList;
@@ -131,26 +131,27 @@ private void OnCaseSensitiveCheckBoxCheckedChanged (object sender, EventArgs e)
UpdateMatches();
}
- private void OnButtonOkClick (object sender, EventArgs e)
+ internal void OnButtonOkClick (object sender, EventArgs e)
{
+ // Both combos are DataSource-bound to the history lists (LoadHistory), so their
+ // Items collections must not be touched — mutate the bound lists instead. The
+ // dialog closes with DialogResult.OK right after, so no rebind is needed.
var text = comboBoxRegex.Text;
_ = ExpressionHistoryList.Remove(text);
ExpressionHistoryList.Insert(0, text);
- comboBoxRegex.Items.Remove(text);
- comboBoxRegex.Items.Insert(0, text);
text = comboBoxTestText.Text;
_ = TesttextHistoryList.Remove(text);
TesttextHistoryList.Insert(0, text);
- if (comboBoxRegex.Items.Count > MAX_HISTORY)
+ if (ExpressionHistoryList.Count > MAX_HISTORY)
{
- comboBoxRegex.Items.Remove(comboBoxRegex.Items.Count - 1);
+ ExpressionHistoryList.RemoveAt(ExpressionHistoryList.Count - 1);
}
- if (comboBoxTestText.Items.Count > MAX_HISTORY)
+ if (TesttextHistoryList.Count > MAX_HISTORY)
{
- comboBoxTestText.Items.Remove(comboBoxTestText.Items.Count - 1);
+ TesttextHistoryList.RemoveAt(TesttextHistoryList.Count - 1);
}
}
diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs
index f5d185bb..7c958309 100644
--- a/src/PluginRegistry/PluginHashGenerator.Generated.cs
+++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs
@@ -10,7 +10,7 @@ public static partial class PluginValidator
{
///
/// Gets pre-calculated SHA256 hashes for built-in plugins.
- /// Generated: 2026-07-09 17:38:54 UTC
+ /// Generated: 2026-07-09 21:05:43 UTC
/// Configuration: Release
/// Plugin count: 21
///
@@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes()
{
return new Dictionary(StringComparer.OrdinalIgnoreCase)
{
- ["AutoColumnizer.dll"] = "CDEFDC033AD707622C49BD4EC937EB9EDE54F8C1EAB19678E29CDC938A91365A",
+ ["AutoColumnizer.dll"] = "81F2CD2C2F2A67C577F3DA2BB1C52884249332B4A2741FC921C18D5AF9F68B30",
["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
- ["CsvColumnizer.dll"] = "4EBBA85D9A259D82342E605E4381CD242A9212186711036E93AFD8DADDA23486",
- ["CsvColumnizer.dll (x86)"] = "4EBBA85D9A259D82342E605E4381CD242A9212186711036E93AFD8DADDA23486",
- ["DefaultPlugins.dll"] = "CB04A2942034D9FF2506379A81D04A438E9C3F55225C28CE4249CB2639724F83",
- ["FlashIconHighlighter.dll"] = "A3DC19BF3D3E660325E54B6ABBEB38A81351DC269CD2C292C7038A0181250B39",
- ["GlassfishColumnizer.dll"] = "55FF72EC8D78E1E33EECE105C625BEEE633A1AB114A9A8F6EF3D7F489770F2BE",
- ["JsonColumnizer.dll"] = "EAF4FE2D20542C7747807083088B222CCE201AF8E9B2287255D0CF76EB52C114",
- ["JsonCompactColumnizer.dll"] = "0F8A8BF3FF85F063D2B219A27F11598954A4A4679C2A972D303836D12E8D7C8E",
- ["Log4jXmlColumnizer.dll"] = "113BD3CACB5D0376BD74DBE98A51E0A4F60405CC0BF10987097234162A3275F0",
- ["LogExpert.Resources.dll"] = "4F151AC6D75E0844E5B4EFEDE591252EE9C998BF61CB9B715E2A6DB7CEA0DBB3",
+ ["CsvColumnizer.dll"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726",
+ ["CsvColumnizer.dll (x86)"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726",
+ ["DefaultPlugins.dll"] = "4B5206DB37D091B48037DE2C03A573BD3678772F412B128C1E57614E1EDC9065",
+ ["FlashIconHighlighter.dll"] = "D12A6517CC0402A8E3A580CFE50F2AB81C15F4019159B784416EC7F8FE73FBA8",
+ ["GlassfishColumnizer.dll"] = "2BA6B760A044FEE75EDC3BC478605E9E0AA9CE4F52C9F435EBA38A9EDD45A109",
+ ["JsonColumnizer.dll"] = "FD407BC814B287588F4DB0FE90BAA104BE5050D7F6097D183F9E0BE906EC8C5E",
+ ["JsonCompactColumnizer.dll"] = "B80B2AD30181E94C08286835CE01C607FA7FD66722EE0C5DB2A7C16AB357A09F",
+ ["Log4jXmlColumnizer.dll"] = "942FCD055F0C9BC2D141D9D2B407BAB14F89719F8AF35F50326F77FC89C74C42",
+ ["LogExpert.Resources.dll"] = "85E2154489ADDC1DC52B67EA0784519AB6427BECB4AD1B8A2BE29D796D3C41C8",
["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"] = "ADAF0EA008A488EB108C6B4C8562EBA96E65A6EAD58ED351DA696C8F7DA02B86",
- ["SftpFileSystem.dll"] = "712F494AA8627BC4D6FA5EE729F5E225BCB61DAE3483D57C8BFD71CBC54841B4",
- ["SftpFileSystem.dll (x86)"] = "F37E477D24B41C506CEE321423E1D5E1EEC731E04E9FD7378ADD695736CE62CC",
- ["SftpFileSystem.Resources.dll"] = "3BD8924373C41D64FCB8AD7A6EE9B4E8FCF5BE311A7E4222A75FECF6567D6BEE",
- ["SftpFileSystem.Resources.dll (x86)"] = "3BD8924373C41D64FCB8AD7A6EE9B4E8FCF5BE311A7E4222A75FECF6567D6BEE",
+ ["RegexColumnizer.dll"] = "ECDDA17BBC01DF90E82065BAE66223568C4F0B44E3F49F595A6178CC4D5A5F5B",
+ ["SftpFileSystem.dll"] = "EA5235D8C1787798A349D38BEE086035AB79AA25DBD1C7C1713FA36379614690",
+ ["SftpFileSystem.dll (x86)"] = "398428B6D28C3FA4494BDD67156CAC72240B0E74D963F24B8B77295CA11C7E93",
+ ["SftpFileSystem.Resources.dll"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890",
+ ["SftpFileSystem.Resources.dll (x86)"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890",
};
}