diff --git a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs
index 0a1e7963..7432df73 100644
--- a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs
+++ b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs
@@ -97,6 +97,55 @@ public Entities.Bookmark GetBookmarkForLine (int lineNum)
#region Internals
+ ///
+ /// Removes all bookmarks where is true.
+ /// Manual bookmarks are not affected. Fires if any were removed.
+ ///
+ public void RemoveAutoGeneratedBookmarks ()
+ {
+ var removed = false;
+
+ lock (_bookmarkListLock)
+ {
+ List keysToRemove = [.. BookmarkList
+ .Where(kvp => kvp.Value.IsAutoGenerated)
+ .Select(kvp => kvp.Key)];
+
+ foreach (var key in keysToRemove)
+ {
+ _ = BookmarkList.Remove(key);
+ removed = true;
+ }
+ }
+
+ if (removed)
+ {
+ OnBookmarkRemoved();
+ }
+ }
+
+ ///
+ /// Converts an auto-generated bookmark at the given line to a manual bookmark.
+ /// Sets to false and clears
+ /// .
+ /// The bookmark will then survive re-scans.
+ ///
+ /// true if a bookmark was found and converted; false otherwise.
+ public bool ConvertToManualBookmark (int lineNum)
+ {
+ lock (_bookmarkListLock)
+ {
+ if (BookmarkList.TryGetValue(lineNum, out var bookmark) && bookmark.IsAutoGenerated)
+ {
+ bookmark.IsAutoGenerated = false;
+ bookmark.SourceHighlightText = null;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public void ShiftBookmarks (int offset)
{
SortedList newBookmarkList = [];
@@ -169,7 +218,6 @@ public void RemoveBookmarksForLines (IEnumerable lineNumList)
OnBookmarkRemoved();
}
- //TOOD: check if the callers are checking for null before calling
public void AddBookmark (Entities.Bookmark bookmark)
{
ArgumentNullException.ThrowIfNull(bookmark, nameof(bookmark));
@@ -181,6 +229,37 @@ public void AddBookmark (Entities.Bookmark bookmark)
OnBookmarkAdded();
}
+ ///
+ /// Adds multiple bookmarks in a single batch operation. Fires
+ /// only once at the end, avoiding per-item event overhead.
+ /// Bookmarks whose already exists in the list are skipped.
+ ///
+ public int AddBookmarks (IEnumerable bookmarks)
+ {
+ ArgumentNullException.ThrowIfNull(bookmarks, nameof(bookmarks));
+
+ var added = 0;
+
+ lock (_bookmarkListLock)
+ {
+ foreach (var bookmark in bookmarks)
+ {
+ if (!BookmarkList.ContainsKey(bookmark.LineNum))
+ {
+ BookmarkList.Add(bookmark.LineNum, bookmark);
+ added++;
+ }
+ }
+ }
+
+ if (added > 0)
+ {
+ OnBookmarkAdded();
+ }
+
+ return added;
+ }
+
public void ClearAllBookmarks ()
{
#if DEBUG
diff --git a/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs
new file mode 100644
index 00000000..afbbc1fd
--- /dev/null
+++ b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs
@@ -0,0 +1,156 @@
+using System.Text;
+
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Highlight;
+
+namespace LogExpert.Core.Classes.Bookmark;
+
+///
+/// Scans all lines of a log file against highlight entries that have set,
+/// producing a list of auto-generated bookmarks. This is a pure computation unit with no UI dependencies.
+///
+public static class HighlightBookmarkScanner
+{
+ ///
+ /// Scans lines [0..lineCount) for highlight matches and returns bookmarks for matching lines.
+ ///
+ /// Total number of lines in the file.
+ ///
+ /// Delegate that returns the log line at a given index. May return null for unavailable lines.
+ ///
+ ///
+ /// The highlight entries to check. Only entries with == true produce
+ /// bookmarks.
+ ///
+ ///
+ /// The file name, passed to for bookmark comment template resolution.
+ ///
+ ///
+ /// Interval of lines for reporting progress via the callback.
+ ///
+ /// Token to support cooperative cancellation.
+ ///
+ /// Optional progress callback receiving the current line index (for progress reporting).
+ ///
+ /// List of auto-generated bookmarks for all matched lines.
+ public static List Scan (int lineCount, Func getLine, IList entries, string fileName, int progressBarModulo = 1000, IProgress progress = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(getLine);
+
+ List result = [];
+
+ // Pre-filter: only entries with IsSetBookmark matter
+ var bookmarkEntries = entries.Where(e => e.IsSetBookmark).ToList();
+ if (bookmarkEntries.Count == 0)
+ {
+ return result;
+ }
+
+ for (var i = 0; i < lineCount; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var line = getLine(i);
+ if (line == null)
+ {
+ continue;
+ }
+
+ var (setBookmark, bookmarkComment, sourceHighlightText) = GetBookmarkAction(line, bookmarkEntries);
+
+ if (setBookmark)
+ {
+ var comment = ResolveComment(bookmarkComment, line, i, fileName);
+ result.Add(Entities.Bookmark.CreateAutoGenerated(i, comment, sourceHighlightText));
+ }
+
+ if (i % progressBarModulo == 0)
+ {
+ progress?.Report(i);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Checks a single line against the bookmark-producing highlight entries. Returns whether a bookmark should be set,
+ /// the concatenated comment template, and the source highlight text.
+ ///
+ private static (bool SetBookmark, string BookmarkComment, string SourceHighlightText) GetBookmarkAction (ITextValueMemory line, List bookmarkEntries)
+ {
+ var setBookmark = false;
+ var bookmarkCommentBuilder = new StringBuilder();
+ var sourceHighlightText = string.Empty;
+
+ foreach (var entry in bookmarkEntries.Where(entry => CheckHighlightEntryMatch(entry, line)))
+ {
+ setBookmark = true;
+ sourceHighlightText = entry.SearchText;
+
+ if (!string.IsNullOrEmpty(entry.BookmarkComment))
+ {
+ _ = bookmarkCommentBuilder.Append(entry.BookmarkComment).Append("\r\n");
+ }
+ }
+
+ return (setBookmark, bookmarkCommentBuilder.ToString().TrimEnd('\r', '\n'), sourceHighlightText);
+ }
+
+ ///
+ /// Matches a highlight entry against a line. Replicates the logic from LogWindow.CheckHighlightEntryMatch so the
+ /// scanner works identically to the existing tail-mode matching.
+ ///
+ private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValueMemory column)
+ {
+ if (entry.IsRegex)
+ {
+ if (entry.Regex.IsMatch(column.Text.ToString()))
+ {
+ return true;
+ }
+ }
+ else
+ {
+ if (entry.IsCaseSensitive)
+ {
+ if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+ else
+ {
+ if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Resolves the bookmark comment template using ParamParser, matching SetBookmarkFromTrigger behavior.
+ ///
+ private static string ResolveComment (string commentTemplate, ILogLineMemory line, int lineNum, string fileName)
+ {
+ if (string.IsNullOrEmpty(commentTemplate))
+ {
+ return commentTemplate;
+ }
+
+ try
+ {
+ var paramParser = new ParamParser(commentTemplate);
+ return paramParser.ReplaceParams(line, lineNum, fileName);
+ }
+ catch (ArgumentException)
+ {
+ // Invalid regex in template — return raw template (matches SetBookmarkFromTrigger behavior)
+ return commentTemplate;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/LogExpert.Core/Classes/Columnizer/TimestampColumnizer.cs b/src/LogExpert.Core/Classes/Columnizer/TimestampColumnizer.cs
index f8a3bb55..f1d2a2d7 100644
--- a/src/LogExpert.Core/Classes/Columnizer/TimestampColumnizer.cs
+++ b/src/LogExpert.Core/Classes/Columnizer/TimestampColumnizer.cs
@@ -46,19 +46,6 @@ public string[] GetColumnNames ()
return ["Date", "Time", "Message"];
}
- ///
- /// Determines the priority level for processing a log file based on the presence of recognizable timestamp formats
- /// in the provided log lines.
- ///
- /// The name of the log file to evaluate. Cannot be null.
- /// A collection of log lines to analyze for timestamp patterns. Cannot be null.
- /// A value indicating the priority for processing the specified log file. Returns Priority.WellSupport if the
- /// majority of log lines contain recognizable timestamps; otherwise, returns Priority.NotSupport.
- public Priority GetPriority (string fileName, IEnumerable samples)
- {
- return GetPriority(fileName, samples.Cast());
- }
-
///
/// Splits a log line into its constituent columns, typically separating date, time, and the remainder of the line.
///
@@ -228,6 +215,14 @@ public void PushValue (ILogLineMemoryColumnizerCallback callback, int column, st
}
}
+ ///
+ /// Determines the priority level for processing a log file based on the presence of recognizable timestamp formats
+ /// in the provided log lines.
+ ///
+ /// The name of the log file to evaluate. Cannot be null.
+ /// A collection of log lines to analyze for timestamp patterns. Cannot be null.
+ /// A value indicating the priority for processing the specified log file. Returns Priority.WellSupport if the
+ /// majority of log lines contain recognizable timestamps; otherwise, returns Priority.NotSupport.
public Priority GetPriority (string fileName, IEnumerable samples)
{
ArgumentNullException.ThrowIfNull(samples, nameof(samples));
diff --git a/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs b/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs
index f146cf3d..5da892ee 100644
--- a/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs
+++ b/src/LogExpert.Core/Classes/Filter/FilterCancelHandler.cs
@@ -6,27 +6,21 @@
namespace LogExpert.Core.Classes.Filter;
-public class FilterCancelHandler : IBackgroundProcessCancelHandler
+public class FilterCancelHandler (FilterStarter filterStarter) : IBackgroundProcessCancelHandler
{
private static readonly ILogger _logger = LogManager.GetCurrentClassLogger();
#region Fields
- private readonly FilterStarter _filterStarter;
+ private readonly FilterStarter _filterStarter = filterStarter;
#endregion
-
#region cTor
- public FilterCancelHandler(FilterStarter filterStarter)
- {
- _filterStarter = filterStarter;
- }
-
#endregion
#region Public methods
- public void EscapePressed()
+ public void EscapePressed ()
{
_logger.Info(CultureInfo.InvariantCulture, "FilterCancelHandler called.");
_filterStarter.CancelFilter();
diff --git a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs
index be14bfbc..be2f03cc 100644
--- a/src/LogExpert.Core/Classes/Filter/FilterStarter.cs
+++ b/src/LogExpert.Core/Classes/Filter/FilterStarter.cs
@@ -147,7 +147,7 @@ private Filter DoWork (FilterParams filterParams, int startLine, int maxCount, P
// Give every thread own copies of ColumnizerCallback and FilterParams, because the state of the objects changes while filtering
var threadFilterParams = filterParams.CloneWithCurrentColumnizer();
- Filter filter = new((ColumnizerCallback)_callback.Clone());
+ Filter filter = new(_callback.Clone());
lock (_filterWorkerList)
{
_filterWorkerList.Add(filter);
diff --git a/src/LogExpert.Core/Classes/Log/LogBuffer.cs b/src/LogExpert.Core/Classes/Log/LogBuffer.cs
index b189e7b9..ae34615d 100644
--- a/src/LogExpert.Core/Classes/Log/LogBuffer.cs
+++ b/src/LogExpert.Core/Classes/Log/LogBuffer.cs
@@ -57,6 +57,8 @@ public long Size
get => _size;
}
+ public int EndLine => StartLine + LineCount;
+
public int StartLine { set; get; }
public int LineCount { get; private set; }
diff --git a/src/LogExpert.Core/Classes/Log/LogfileReader.cs b/src/LogExpert.Core/Classes/Log/LogfileReader.cs
index 57f9eb1a..ad2b43ce 100644
--- a/src/LogExpert.Core/Classes/Log/LogfileReader.cs
+++ b/src/LogExpert.Core/Classes/Log/LogfileReader.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Runtime.InteropServices;
using System.Text;
using ColumnizerLib;
@@ -36,7 +37,7 @@ public partial class LogfileReader : IAutoLogLineMemoryColumnizerCallback, IDisp
private const int PROGRESS_UPDATE_INTERVAL_MS = 100;
private const int WAIT_TIME = 1000;
- private IList _bufferList;
+ private List _bufferList;
private bool _contentDeleted;
private DateTime _lastProgressUpdate = DateTime.MinValue;
@@ -54,6 +55,8 @@ public partial class LogfileReader : IAutoLogLineMemoryColumnizerCallback, IDisp
private bool _disposed;
private ILogFileInfo _watchedILogFileInfo;
+ private volatile int _lastBufferIndex = -1;
+
#endregion
#region cTor
@@ -141,8 +144,10 @@ private LogfileReader (string[] fileNames, EncodingOptions encodingOptions, bool
///
/// Gets the total number of lines contained in all buffers.
///
- /// The value is recalculated on demand if the underlying buffers have changed since the last
- /// access. Accessing this property is thread-safe.
+ ///
+ /// The value is recalculated on demand if the underlying buffers have changed since the last access. Accessing this
+ /// property is thread-safe.
+ ///
public int LineCount
{
get
@@ -233,13 +238,14 @@ private EncodingOptions EncodingOptions
///
/// Reads all log files and refreshes the internal buffer and related state to reflect the current contents of the
- /// files.
- /// Public for unit test reasons
+ /// files. Public for unit test reasons
///
- /// This method resets file size and line count tracking, clears any cached data, and repopulates
- /// the buffer with the latest data from the log files. If an I/O error occurs while reading the files, the internal
- /// state is updated to indicate that the files are unavailable. After reading, a file size changed event is raised
- /// to notify listeners of the update.
+ ///
+ /// This method resets file size and line count tracking, clears any cached data, and repopulates the buffer with
+ /// the latest data from the log files. If an I/O error occurs while reading the files, the internal state is
+ /// updated to indicate that the files are unavailable. After reading, a file size changed event is raised to notify
+ /// listeners of the update.
+ ///
//TODO: Make this private
public void ReadFiles ()
{
@@ -290,20 +296,24 @@ public void ReadFiles ()
///
/// Synchronizes the internal buffer state with the current set of log files, updating or removing buffers as
- /// necessary to reflect file changes.
- /// Public for unit tests.
- ///
- /// Call this method after external changes to the underlying log files, such as file rotation or
- /// deletion, to ensure the buffer accurately represents the current log file set. This method may remove, update,
- /// or re-read buffers to match the current files. Thread safety is ensured during the operation.
- /// The total number of lines removed from the buffer as a result of deleted or replaced log files. Returns 0 if no
- /// lines were removed.
+ /// necessary to reflect file changes. Public for unit tests.
+ ///
+ ///
+ /// Call this method after external changes to the underlying log files, such as file rotation or deletion, to
+ /// ensure the buffer accurately represents the current log file set. This method may remove, update, or re-read
+ /// buffers to match the current files. Thread safety is ensured during the operation.
+ ///
+ ///
+ /// The total number of lines removed from the buffer as a result of deleted or replaced log files. Returns 0 if no
+ /// lines were removed.
+ ///
//TODO: Make this private
public int ShiftBuffers ()
{
_logger.Info(CultureInfo.InvariantCulture, "ShiftBuffers() begin for {0}{1}", _fileName, IsMultiFile ? " (MultiFile)" : "");
AcquireBufferListWriterLock();
+ ClearBufferState();
var offset = 0;
_isLineCountDirty = true;
@@ -460,9 +470,10 @@ public int ShiftBuffers ()
/// Acquires a read lock on the buffer list, waiting up to 10 seconds before forcing entry if the lock is not
/// immediately available.
///
- /// If the read lock cannot be acquired within 10 seconds, the method will forcibly enter the
- /// lock and log a warning. Callers should ensure that holding the read lock for extended periods does not block
- /// other operations.
+ ///
+ /// If the read lock cannot be acquired within 10 seconds, the method will forcibly enter the lock and log a
+ /// warning. Callers should ensure that holding the read lock for extended periods does not block other operations.
+ ///
private void AcquireBufferListReaderLock ()
{
if (!_bufferListLock.TryEnterReadLock(TimeSpan.FromSeconds(10)))
@@ -475,9 +486,10 @@ private void AcquireBufferListReaderLock ()
///
/// Releases the reader lock on the buffer list, allowing other threads to acquire write access.
///
- /// Call this method after completing operations that require read access to the buffer list.
- /// Failing to release the reader lock may result in deadlocks or prevent other threads from obtaining write
- /// access.
+ ///
+ /// Call this method after completing operations that require read access to the buffer list. Failing to release the
+ /// reader lock may result in deadlocks or prevent other threads from obtaining write access.
+ ///
private void ReleaseBufferListReaderLock ()
{
_bufferListLock.ExitReadLock();
@@ -486,8 +498,10 @@ private void ReleaseBufferListReaderLock ()
///
/// Releases the writer lock on the buffer list, allowing other threads to acquire the lock.
///
- /// Call this method after completing operations that required exclusive access to the buffer
- /// list. Failing to release the writer lock may result in deadlocks or reduced concurrency.
+ ///
+ /// Call this method after completing operations that required exclusive access to the buffer list. Failing to
+ /// release the writer lock may result in deadlocks or reduced concurrency.
+ ///
private void ReleaseBufferListWriterLock ()
{
_bufferListLock.ExitWriteLock();
@@ -496,8 +510,10 @@ private void ReleaseBufferListWriterLock ()
///
/// Releases an upgradeable read lock held by the current thread on the associated lock object.
///
- /// Call this method to exit an upgradeable read lock previously acquired on the underlying lock.
- /// Failing to release the lock may result in deadlocks or resource contention.
+ ///
+ /// Call this method to exit an upgradeable read lock previously acquired on the underlying lock. Failing to release
+ /// the lock may result in deadlocks or resource contention.
+ ///
private void ReleaseDisposeUpgradeableReadLock ()
{
_disposeLock.ExitUpgradeableReadLock();
@@ -506,9 +522,11 @@ private void ReleaseDisposeUpgradeableReadLock ()
///
/// Acquires the writer lock for the buffer list, blocking the calling thread until the lock is obtained.
///
- /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method
- /// will continue to wait until the lock becomes available. This method should be used to ensure exclusive access to
- /// the buffer list when performing write operations.
+ ///
+ /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method will continue to
+ /// wait until the lock becomes available. This method should be used to ensure exclusive access to the buffer list
+ /// when performing write operations.
+ ///
private void AcquireBufferListWriterLock ()
{
if (!_bufferListLock.TryEnterWriteLock(TimeSpan.FromSeconds(10)))
@@ -525,17 +543,18 @@ public ILogLineMemory GetLogLineMemory (int lineNum)
}
///
- /// Get the text content of the given line number.
- /// The actual work is done in an async thread. This method waits for thread completion for only 1 second. If the async
- /// thread has not returned, the method will return null. This is because this method is also called from GUI thread
- /// (e.g. LogWindow draw events). Under some circumstances, repeated calls to this method would lead the GUI to freeze. E.g. when
- /// trying to re-load content from disk but the file was deleted. Especially on network shares.
+ /// Get the text content of the given line number. The actual work is done in an async thread. This method waits for
+ /// thread completion for only 1 second. If the async thread has not returned, the method will return
+ /// null. This is because this method is also called from GUI thread (e.g. LogWindow draw events). Under some
+ /// circumstances, repeated calls to this method would lead the GUI to freeze. E.g. when trying to re-load content
+ /// from disk but the file was deleted. Especially on network shares.
///
///
- /// Once the method detects a timeout it will enter a kind of 'fast fail mode'. That means all following calls will be returned with
- /// null immediately (without 1 second wait). A background call to GetLogLineInternal() will check if a result is available.
- /// If so, the 'fast fail mode' is switched off. In most cases a fail is caused by a deleted file. But it may also be caused by slow
- /// network connections. So all this effort is needed to prevent entering an endless 'fast fail mode' just because of temporary problems.
+ /// Once the method detects a timeout it will enter a kind of 'fast fail mode'. That means all following calls will
+ /// be returned with null immediately (without 1 second wait). A background call to
+ /// GetLogLineInternal() will check if a result is available. If so, the 'fast fail mode' is switched off. In most
+ /// cases a fail is caused by a deleted file. But it may also be caused by slow network connections. So all this
+ /// effort is needed to prevent entering an endless 'fast fail mode' just because of temporary problems.
///
/// line to retrieve
///
@@ -598,8 +617,7 @@ public ILogFileInfo GetLogFileInfoForLine (int lineNum)
}
///
- /// Returns the line number (starting from the given number) where the next multi file
- /// starts.
+ /// Returns the line number (starting from the given number) where the next multi file starts.
///
///
///
@@ -632,10 +650,14 @@ public int GetNextMultiFileLine (int lineNum)
/// Finds the starting line number of the previous file segment before the specified line number across multiple
/// files.
///
- /// This method is useful when navigating through a collection of files represented as contiguous
- /// line segments. If the specified line number is within the first file segment, the method returns -1 to indicate
- /// that there is no previous file segment.
- /// The line number for which to locate the previous file segment. Must be a valid line number within the buffer.
+ ///
+ /// This method is useful when navigating through a collection of files represented as contiguous line segments. If
+ /// the specified line number is within the first file segment, the method returns -1 to indicate that there is no
+ /// previous file segment.
+ ///
+ ///
+ /// The line number for which to locate the previous file segment. Must be a valid line number within the buffer.
+ ///
/// The starting line number of the previous file segment if one exists; otherwise, -1.
public int GetPrevMultiFileLine (int lineNum)
{
@@ -663,11 +685,11 @@ public int GetPrevMultiFileLine (int lineNum)
}
///
- /// Returns the actual line number in the file for the given 'virtual line num'.
- /// This is needed for multi file mode. 'Virtual' means that the given line num is a line
- /// number in the collections of the files currently viewed together in multi file mode as one large virtual file.
- /// This method finds the real file for the line number and maps the line number to the correct position
- /// in that file. This is needed when launching external tools to provide correct line number arguments.
+ /// Returns the actual line number in the file for the given 'virtual line num'. This is needed for multi file mode.
+ /// 'Virtual' means that the given line num is a line number in the collections of the files currently viewed
+ /// together in multi file mode as one large virtual file. This method finds the real file for the line number and
+ /// maps the line number to the correct position in that file. This is needed when launching external tools to
+ /// provide correct line number arguments.
///
///
///
@@ -692,9 +714,11 @@ public int GetRealLineNumForVirtualLineNum (int lineNum)
///
/// Begins monitoring by starting the background monitoring process.
///
- /// This method initiates monitoring if it is not already running. To stop monitoring, call the
- /// corresponding stop method if available. This method is not thread-safe; ensure that it is not called
- /// concurrently with other monitoring control methods.
+ ///
+ /// This method initiates monitoring if it is not already running. To stop monitoring, call the corresponding stop
+ /// method if available. This method is not thread-safe; ensure that it is not called concurrently with other
+ /// monitoring control methods.
+ ///
public void StartMonitoring ()
{
_logger.Info(CultureInfo.InvariantCulture, "startMonitoring()");
@@ -705,8 +729,10 @@ public void StartMonitoring ()
///
/// Stops monitoring the log file and terminates any background monitoring or cleanup tasks.
///
- /// Call this method to halt all ongoing monitoring activity and release associated resources.
- /// After calling this method, monitoring cannot be resumed without restarting the monitoring process.
+ ///
+ /// Call this method to halt all ongoing monitoring activity and release associated resources. After calling this
+ /// method, monitoring cannot be resumed without restarting the monitoring process.
+ ///
public void StopMonitoring ()
{
_logger.Info(CultureInfo.InvariantCulture, "stopMonitoring()");
@@ -737,8 +763,8 @@ public void StopMonitoring ()
}
///
- /// calls stopMonitoring() in a background thread and returns to the caller immediately.
- /// This is useful for a fast responding GUI (e.g. when closing a file tab)
+ /// calls stopMonitoring() in a background thread and returns to the caller immediately. This is useful for a fast
+ /// responding GUI (e.g. when closing a file tab)
///
public void StopMonitoringAsync ()
{
@@ -752,8 +778,7 @@ public void StopMonitoringAsync ()
}
///
- /// Deletes all buffer lines and disposes their content. Use only when the LogfileReader
- /// is about to be closed!
+ /// Deletes all buffer lines and disposes their content. Use only when the LogfileReader is about to be closed!
///
public void DeleteAllContent ()
{
@@ -765,6 +790,7 @@ public void DeleteAllContent ()
_logger.Info(CultureInfo.InvariantCulture, "Deleting all log buffers for {0}. Used mem: {1:N0}", Util.GetNameFromPath(_fileName), GC.GetTotalMemory(true)); //TODO [Z] uh GC collect calls creepy
AcquireBufferListWriterLock();
+ ClearBufferState();
AcquireLruCacheDictWriterLock();
AcquireDisposeWriterLock();
@@ -787,6 +813,14 @@ public void DeleteAllContent ()
_logger.Info(CultureInfo.InvariantCulture, "Deleting complete. Used mem: {0:N0}", GC.GetTotalMemory(true)); //TODO [Z] uh GC collect calls creepy
}
+ ///
+ /// Clears the Buffer so that no stale buffer references are kept
+ ///
+ private void ClearBufferState ()
+ {
+ _lastBufferIndex = -1;
+ }
+
///
/// Explicit change the encoding.
///
@@ -826,8 +860,10 @@ public IList GetBufferList ()
///
/// Logs detailed buffer information for the specified line number to the debug output.
///
- /// This method is intended for debugging purposes and is only available in debug builds. It logs
- /// buffer details and file position information to assist with diagnostics.
+ ///
+ /// This method is intended for debugging purposes and is only available in debug builds. It logs buffer details and
+ /// file position information to assist with diagnostics.
+ ///
/// The zero-based line number for which buffer information is logged.
public void LogBufferInfoForLine (int lineNum)
{
@@ -853,9 +889,11 @@ public void LogBufferInfoForLine (int lineNum)
///
/// Logs diagnostic information about the current state of the buffer and LRU cache for debugging purposes.
///
- /// This method is intended for use in debug builds to assist with troubleshooting and analyzing
- /// buffer management. It outputs details such as the number of LRU cache entries, buffer counts, and dispose
- /// statistics to the logger. This method does not modify the state of the buffers or cache.
+ ///
+ /// This method is intended for use in debug builds to assist with troubleshooting and analyzing buffer management.
+ /// It outputs details such as the number of LRU cache entries, buffer counts, and dispose statistics to the logger.
+ /// This method does not modify the state of the buffers or cache.
+ ///
public void LogBufferDiagnostic ()
{
_logger.Info(CultureInfo.InvariantCulture, "-------- Buffer diagnostics -------");
@@ -916,8 +954,10 @@ private ILogFileInfo AddFile (string fileName)
/// cannot be found.
///
/// The zero-based line number of the log entry to retrieve.
- /// A task that represents the asynchronous operation. The task result contains the log line at the specified line
- /// number, or null if the file is deleted or the line does not exist.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains the log line at the specified line
+ /// number, or null if the file is deleted or the line does not exist.
+ ///
private Task GetLogLineMemoryInternal (int lineNum)
{
if (_isDeleted)
@@ -959,11 +999,13 @@ private Task GetLogLineMemoryInternal (int lineNum)
///
/// Initializes the internal data structures used for least recently used (LRU) buffer management.
///
- /// Call this method to reset or prepare the LRU buffer cache before use. This method clears any
- /// existing buffer state and sets up the cache to track buffer usage according to the configured maximum buffer
- /// count.
+ ///
+ /// Call this method to reset or prepare the LRU buffer cache before use. This method clears any existing buffer
+ /// state and sets up the cache to track buffer usage according to the configured maximum buffer count.
+ ///
private void InitLruBuffers ()
{
+ ClearBufferState();
_bufferList = [];
//_bufferLru = new List(_max_buffers + 1);
//this.lruDict = new Dictionary(this.MAX_BUFFERS + 1); // key=startline, value = index in bufferLru
@@ -973,9 +1015,11 @@ private void InitLruBuffers ()
///
/// Starts the background task responsible for performing garbage collection operations.
///
- /// This method initiates the garbage collection process on a separate thread or task. It is
- /// intended for internal use to manage resource cleanup asynchronously. Calling this method multiple times without
- /// proper synchronization may result in multiple concurrent garbage collection tasks.
+ ///
+ /// This method initiates the garbage collection process on a separate thread or task. It is intended for internal
+ /// use to manage resource cleanup asynchronously. Calling this method multiple times without proper synchronization
+ /// may result in multiple concurrent garbage collection tasks.
+ ///
private void StartGCThread ()
{
_garbageCollectorTask = Task.Run(GarbageCollectorThreadProc, _cts.Token);
@@ -987,9 +1031,10 @@ private void StartGCThread ()
///
/// Resets the internal buffer cache, clearing any stored file size and line count information.
///
- /// Call this method to reinitialize the buffer cache state, typically before reloading or
- /// reprocessing file data. After calling this method, any previously cached file size or line count values will be
- /// lost.
+ ///
+ /// Call this method to reinitialize the buffer cache state, typically before reloading or reprocessing file data.
+ /// After calling this method, any previously cached file size or line count values will be lost.
+ ///
private void ResetBufferCache ()
{
FileSize = 0;
@@ -1018,9 +1063,13 @@ private void CloseFiles ()
///
/// Retrieves information about a log file specified by its file name or URI.
///
- /// The file name or URI identifying the log file for which to retrieve information. Cannot be null or empty.
+ ///
+ /// The file name or URI identifying the log file for which to retrieve information. Cannot be null or empty.
+ ///
/// An object containing information about the specified log file.
- /// Thrown if no file system plugin is found for the specified file name or URI, or if the log file cannot be found.
+ ///
+ /// Thrown if no file system plugin is found for the specified file name or URI, or if the log file cannot be found.
+ ///
private ILogFileInfo GetLogFileInfo (string fileNameOrUri) //TODO: I changed to static
{
//TODO this must be fixed and should be given to the logfilereader not just called (https://github.com/LogExperts/LogExpert/issues/402)
@@ -1032,11 +1081,15 @@ private ILogFileInfo GetLogFileInfo (string fileNameOrUri) //TODO: I changed to
///
/// Replaces references to an existing log file information object with a new one in all managed buffers.
///
- /// This method updates all buffer entries that reference the specified old log file information object,
- /// assigning them the new log file information object instead. Use this method when a log file has been renamed or its
- /// metadata has changed, and all associated buffers need to reference the updated information.
+ ///
+ /// This method updates all buffer entries that reference the specified old log file information object, assigning
+ /// them the new log file information object instead. Use this method when a log file has been renamed or its
+ /// metadata has changed, and all associated buffers need to reference the updated information.
+ ///
/// The log file information object to be replaced. Cannot be null.
- /// The new log file information object to use as a replacement. Cannot be null.
+ ///
+ /// The new log file information object to use as a replacement. Cannot be null.
+ ///
private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLogFileInfo)
{
_logger.Debug(CultureInfo.InvariantCulture, "ReplaceBufferInfos() " + oldLogFileInfo.FullName + " -> " + newLogFileInfo.FullName);
@@ -1054,14 +1107,21 @@ private void ReplaceBufferInfos (ILogFileInfo oldLogFileInfo, ILogFileInfo newLo
/// Deletes all log buffers associated with the specified log file information and returns the last buffer that was
/// removed.
///
- /// If multiple buffers match the specified criteria, all are removed and the last one found is
- /// returned. If no buffers match, the method returns null.
- /// The log file information used to identify which buffers to delete. Cannot be null.
- /// true to match buffers by file name only; false to require an exact object match for the log file information.
+ ///
+ /// If multiple buffers match the specified criteria, all are removed and the last one found is returned. If no
+ /// buffers match, the method returns null.
+ ///
+ ///
+ /// The log file information used to identify which buffers to delete. Cannot be null.
+ ///
+ ///
+ /// true to match buffers by file name only; false to require an exact object match for the log file information.
+ ///
/// The last LogBuffer instance that was removed; or null if no matching buffers were found.
private LogBuffer DeleteBuffersForInfo (ILogFileInfo iLogFileInfo, bool matchNamesOnly)
{
_logger.Info($"Deleting buffers for file {iLogFileInfo.FullName}");
+ ClearBufferState();
LogBuffer lastRemovedBuffer = null;
IList deleteList = [];
@@ -1106,11 +1166,13 @@ private LogBuffer DeleteBuffersForInfo (ILogFileInfo iLogFileInfo, bool matchNam
}
///
- /// Removes the specified log buffer from the internal buffer list and associated cache.
- /// The caller must have _writer locks for lruCache and buffer list!
+ /// Removes the specified log buffer from the internal buffer list and associated cache. The caller must have
+ /// _writer locks for lruCache and buffer list!
///
- /// This method must be called only when the appropriate write locks for both the LRU cache and
- /// buffer list are held. Removing a buffer that is not present has no effect.
+ ///
+ /// This method must be called only when the appropriate write locks for both the LRU cache and buffer list are
+ /// held. Removing a buffer that is not present has no effect.
+ ///
/// The log buffer to remove from the buffer list and cache. Must not be null.
private void RemoveFromBufferList (LogBuffer buffer)
{
@@ -1124,14 +1186,18 @@ private void RemoveFromBufferList (LogBuffer buffer)
/// Reads log lines from the specified log file starting at the given file position and line number, and populates
/// the internal buffer list with the read data.
///
- /// If the buffer list is empty or the log file changes, a new buffer is created. The method
- /// updates internal state such as file size, encoding, and line count, and may trigger events to notify about file
- /// loading progress or file not found conditions. This method is not thread-safe and should be called with
- /// appropriate synchronization if accessed concurrently.
+ ///
+ /// If the buffer list is empty or the log file changes, a new buffer is created. The method updates internal state
+ /// such as file size, encoding, and line count, and may trigger events to notify about file loading progress or
+ /// file not found conditions. This method is not thread-safe and should be called with appropriate synchronization
+ /// if accessed concurrently.
+ ///
/// The log file information used to open and read the file. Must not be null.
/// The byte position in the file at which to begin reading.
- /// The line number corresponding to the starting position in the file. Used to assign line numbers to buffered log
- /// lines.
+ ///
+ /// The line number corresponding to the starting position in the file. Used to assign line numbers to buffered log
+ /// lines.
+ ///
private void ReadToBufferList (ILogFileInfo logFileInfo, long filePos, int startLine)
{
try
@@ -1335,9 +1401,10 @@ private void AddBufferToList (LogBuffer logBuffer)
/// Updates the least recently used (LRU) cache with the specified log buffer, adding it if it does not already
/// exist or marking it as recently used if it does.
///
- /// If the specified log buffer is not already present in the cache, it is added. If it is
- /// present, its usage is updated to reflect recent access. This method is thread-safe and manages cache locks
- /// internally.
+ ///
+ /// If the specified log buffer is not already present in the cache, it is added. If it is present, its usage is
+ /// updated to reflect recent access. This method is thread-safe and manages cache locks internally.
+ ///
/// The log buffer to add to or update in the LRU cache. Cannot be null.
private void UpdateLruCache (LogBuffer logBuffer)
{
@@ -1398,8 +1465,8 @@ private void UpdateLruCache (LogBuffer logBuffer)
}
///
- /// Sets a new start line in the given buffer and updates the LRU cache, if the buffer
- /// is present in the cache. The caller must have write lock for 'lruCacheDictLock';
+ /// Sets a new start line in the given buffer and updates the LRU cache, if the buffer is present in the cache. The
+ /// caller must have write lock for 'lruCacheDictLock';
///
///
///
@@ -1425,10 +1492,11 @@ private void SetNewStartLineForBuffer (LogBuffer logBuffer, int newLineNum)
///
/// Removes least recently used entries from the LRU cache to maintain the cache size within the configured limit.
///
- /// This method is intended to be called when the LRU cache exceeds its maximum allowed size. It
- /// removes the least recently used entries to free up resources and ensure optimal cache performance. The method is
- /// not thread-safe and should be called only when appropriate locks are held to prevent concurrent
- /// modifications.
+ ///
+ /// This method is intended to be called when the LRU cache exceeds its maximum allowed size. It removes the least
+ /// recently used entries to free up resources and ensure optimal cache performance. The method is not thread-safe
+ /// and should be called only when appropriate locks are held to prevent concurrent modifications.
+ ///
private void GarbageCollectLruCache ()
{
#if DEBUG
@@ -1489,9 +1557,11 @@ private void GarbageCollectLruCache ()
/// Executes the background thread procedure responsible for periodically triggering garbage collection of the least
/// recently used (LRU) cache while the thread is active.
///
- /// This method is intended to run on a dedicated background thread. It repeatedly waits for a
- /// fixed interval and then invokes cache cleanup, continuing until a stop signal is received. Exceptions during the
- /// sleep interval are caught and ignored to ensure the thread remains active.
+ ///
+ /// This method is intended to run on a dedicated background thread. It repeatedly waits for a fixed interval and
+ /// then invokes cache cleanup, continuing until a stop signal is received. Exceptions during the sleep interval are
+ /// caught and ignored to ensure the thread remains active.
+ ///
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Garbage collector Thread Process")]
private void GarbageCollectorThreadProc ()
{
@@ -1512,9 +1582,11 @@ private void GarbageCollectorThreadProc ()
///
/// Clears all entries from the least recently used (LRU) cache and releases associated resources.
///
- /// Call this method to remove all items from the LRU cache and dispose of their contents. This
- /// operation is typically used to free memory or reset the cache state. The method is not thread-safe and should be
- /// called only when appropriate synchronization is ensured.
+ ///
+ /// Call this method to remove all items from the LRU cache and dispose of their contents. This operation is
+ /// typically used to free memory or reset the cache state. The method is not thread-safe and should be called only
+ /// when appropriate synchronization is ensured.
+ ///
private void ClearLru ()
{
_logger.Info(CultureInfo.InvariantCulture, "Clearing LRU cache.");
@@ -1535,10 +1607,13 @@ private void ClearLru ()
/// Re-reads the contents of the specified log buffer from its associated file, updating its lines and dropped line
/// count as necessary.
///
- /// This method acquires a lock on the provided log buffer during the operation to ensure thread
- /// safety. If an I/O error occurs while accessing the file, the method logs a warning and returns without updating
- /// the buffer.
- /// The log buffer to refresh with the latest data from its underlying file. Cannot be null.
+ ///
+ /// This method acquires a lock on the provided log buffer during the operation to ensure thread safety. If an I/O
+ /// error occurs while accessing the file, the method logs a warning and returns without updating the buffer.
+ ///
+ ///
+ /// The log buffer to refresh with the latest data from its underlying file. Cannot be null.
+ ///
private void ReReadBuffer (LogBuffer logBuffer)
{
#if DEBUG
@@ -1625,37 +1700,156 @@ private void ReReadBuffer (LogBuffer logBuffer)
///
/// Retrieves the log buffer that contains the specified line number.
///
- /// The zero-based line number for which to retrieve the corresponding log buffer. Must be greater than or equal to
- /// zero.
- /// The instance that contains the specified line number, or if no
- /// such buffer exists.
+ ///
+ /// The zero-based line number for which to retrieve the corresponding log buffer. Must be greater than or equal to
+ /// zero.
+ ///
+ ///
+ /// The instance that contains the specified line number, or if no
+ /// such buffer exists.
+ ///
+ // private LogBuffer GetBufferForLine (int lineNum)
+ // {
+ //#if DEBUG
+ // long startTime = Environment.TickCount;
+ //#endif
+ // LogBuffer logBuffer = null;
+ // AcquireBufferListReaderLock();
+
+ // var startIndex = 0;
+ // var count = _bufferList.Count;
+ // for (var i = startIndex; i < count; ++i)
+ // {
+ // logBuffer = _bufferList[i];
+ // if (lineNum >= logBuffer.StartLine && lineNum < logBuffer.StartLine + logBuffer.LineCount)
+ // {
+ // UpdateLruCache(logBuffer);
+ // break;
+ // }
+ // }
+ //#if DEBUG
+ // long endTime = Environment.TickCount;
+ // //_logger.logDebug("getBufferForLine(" + lineNum + ") duration: " + ((endTime - startTime)) + " ms. Buffer start line: " + logBuffer.StartLine);
+ //#endif
+ // ReleaseBufferListReaderLock();
+ // return logBuffer;
+ // }
+
+ ///
+ /// Retrieves the log buffer that contains the specified line number.
+ ///
+ ///
+ /// The zero-based line number for which to retrieve the corresponding log buffer. Must be greater than or equal to
+ /// zero.
+ ///
+ ///
+ /// The instance that contains the specified line number, or if no
+ /// such buffer exists.
+ ///
private LogBuffer GetBufferForLine (int lineNum)
{
#if DEBUG
long startTime = Environment.TickCount;
#endif
- LogBuffer logBuffer = null;
- AcquireBufferListReaderLock();
- var startIndex = 0;
- var count = _bufferList.Count;
- for (var i = startIndex; i < count; ++i)
+ AcquireBufferListReaderLock();
+ try
{
- logBuffer = _bufferList[i];
- if (lineNum >= logBuffer.StartLine && lineNum < logBuffer.StartLine + logBuffer.LineCount)
+ var arr = CollectionsMarshal.AsSpan(_bufferList);
+ var count = arr.Length;
+
+ if (count == 0)
{
- UpdateLruCache(logBuffer);
- break;
+ return null;
+ }
+
+ // Layer 0: Last buffer cache — O(1) for sequential access
+ var lastIdx = _lastBufferIndex;
+ if (lastIdx >= 0 && lastIdx < count)
+ {
+ var buf = arr[lastIdx];
+ if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount)
+ {
+ UpdateLruCache(buf);
+ return buf;
+ }
+
+ // Layer 1: Adjacent buffer prediction — O(1) for buffer boundary crossings
+ if (lastIdx + 1 < count)
+ {
+ var next = arr[lastIdx + 1];
+ if ((uint)(lineNum - next.StartLine) < (uint)next.LineCount)
+ {
+ _lastBufferIndex = lastIdx + 1;
+ UpdateLruCache(next);
+ return next;
+ }
+ }
+
+ if (lastIdx - 1 >= 0)
+ {
+ var prev = arr[lastIdx - 1];
+ if ((uint)(lineNum - prev.StartLine) < (uint)prev.LineCount)
+ {
+ _lastBufferIndex = lastIdx - 1;
+ UpdateLruCache(prev);
+ return prev;
+ }
+ }
+ }
+
+ // Layer 2: Direct mapping guess — O(1) speculative for uniform buffers
+ var guess = lineNum / _maxLinesPerBuffer;
+ if ((uint)guess < (uint)count)
+ {
+ var buf = arr[guess];
+ if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount)
+ {
+ _lastBufferIndex = guess;
+ UpdateLruCache(buf);
+ return buf;
+ }
}
+
+ // Layer 3: Branchless binary search with power-of-two strides
+ var step = HighestPowerOfTwo(count);
+ var idx = (arr[step - 1].StartLine <= lineNum) ? count - step : 0;
+
+ for (step >>= 1; step > 0; step >>= 1)
+ {
+ var probe = idx + step;
+ if (probe < count && arr[probe - 1].StartLine <= lineNum)
+ {
+ idx = probe;
+ }
+ }
+
+ // idx is now the buffer index — verify bounds
+ if (idx < count)
+ {
+ var buf = arr[idx];
+ if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount)
+ {
+ _lastBufferIndex = idx;
+ UpdateLruCache(buf);
+ return buf;
+ }
+ }
+
+ return null;
}
+ finally
+ {
#if DEBUG
- long endTime = Environment.TickCount;
- //_logger.logDebug("getBufferForLine(" + lineNum + ") duration: " + ((endTime - startTime)) + " ms. Buffer start line: " + logBuffer.StartLine);
+ long endTime = Environment.TickCount;
+ //_logger.logDebug("getBufferForLine(" + lineNum + ") duration: " + ((endTime - startTime)) + " ms.");
#endif
- ReleaseBufferListReaderLock();
- return logBuffer;
+ ReleaseBufferListReaderLock();
+ }
}
+ private static int HighestPowerOfTwo (int n) => 1 << (31 - int.LeadingZeroCount(n));
+
private void GetLineMemoryFinishedCallback (ILogLineMemory line)
{
_isFailModeCheckCallPending = false;
@@ -1672,11 +1866,15 @@ private void GetLineMemoryFinishedCallback (ILogLineMemory line)
/// Finds the first buffer in the buffer list that is associated with the same file as the specified log buffer,
/// searching backwards from the given buffer.
///
- /// This method searches backwards from the specified buffer in the buffer list to locate the
- /// earliest buffer associated with the same file. The search is inclusive of the starting buffer.
+ ///
+ /// This method searches backwards from the specified buffer in the buffer list to locate the earliest buffer
+ /// associated with the same file. The search is inclusive of the starting buffer.
+ ///
/// The log buffer from which to begin the search. Must not be null.
- /// The first LogBuffer in the buffer list that is associated with the same file as the specified buffer, searching
- /// in reverse order from the given buffer. Returns null if the specified buffer is not found in the buffer list.
+ ///
+ /// The first LogBuffer in the buffer list that is associated with the same file as the specified buffer, searching
+ /// in reverse order from the given buffer. Returns null if the specified buffer is not found in the buffer list.
+ ///
private LogBuffer GetFirstBufferForFileByLogBuffer (LogBuffer logBuffer)
{
var info = logBuffer.FileInfo;
@@ -1707,10 +1905,12 @@ private LogBuffer GetFirstBufferForFileByLogBuffer (LogBuffer logBuffer)
///
/// Monitors the specified log file for changes and processes updates in a background thread.
///
- /// This method is intended to be used as the entry point for a monitoring thread. It
- /// periodically checks the watched log file for changes, handles file not found scenarios, and triggers appropriate
- /// events when the file is updated or deleted. The method runs until a stop signal is received. Exceptions
- /// encountered during monitoring are logged but do not terminate the monitoring loop.
+ ///
+ /// This method is intended to be used as the entry point for a monitoring thread. It periodically checks the
+ /// watched log file for changes, handles file not found scenarios, and triggers appropriate events when the file is
+ /// updated or deleted. The method runs until a stop signal is received. Exceptions encountered during monitoring
+ /// are logged but do not terminate the monitoring loop.
+ ///
private void MonitorThreadProc ()
{
Thread.CurrentThread.Name = "MonitorThread";
@@ -1783,9 +1983,11 @@ private void MonitorThreadProc ()
/// Handles the scenario when the monitored file is not found and updates the internal state to reflect that the
/// file has been deleted.
///
- /// This method should be called when a monitored file is determined to be missing, such as after
- /// a FileNotFoundException. It transitions the monitoring logic into a 'deleted' state and notifies any listeners
- /// of the file's absence. Subsequent calls have no effect if the file is already marked as deleted.
+ ///
+ /// This method should be called when a monitored file is determined to be missing, such as after a
+ /// FileNotFoundException. It transitions the monitoring logic into a 'deleted' state and notifies any listeners of
+ /// the file's absence. Subsequent calls have no effect if the file is already marked as deleted.
+ ///
private void MonitoredFileNotFound ()
{
long oldSize;
@@ -1808,9 +2010,11 @@ private void MonitoredFileNotFound ()
///
/// Handles updates when the underlying file has changed, such as when it is modified or restored after deletion.
///
- /// This method should be called when the file being monitored is detected to have changed. If
- /// the file was previously deleted and has been restored, the method triggers a respawn event and resets the file
- /// size. It also logs the change and notifies listeners of the update.
+ ///
+ /// This method should be called when the file being monitored is detected to have changed. If the file was
+ /// previously deleted and has been restored, the method triggers a respawn event and resets the file size. It also
+ /// logs the change and notifies listeners of the update.
+ ///
private void FileChanged ()
{
if (_isDeleted)
@@ -1832,10 +2036,12 @@ private void FileChanged ()
/// Raises a change event to notify listeners of updates to the monitored file, such as changes in file size, line
/// count, or file rollover events.
///
- /// This method should be called whenever the state of the monitored file may have changed,
- /// including when the file is recreated, deleted, or rolled over. It updates relevant event arguments and invokes
- /// event handlers as appropriate. Listeners can use the event data to respond to file changes, such as updating UI
- /// elements or processing new log entries.
+ ///
+ /// This method should be called whenever the state of the monitored file may have changed, including when the file
+ /// is recreated, deleted, or rolled over. It updates relevant event arguments and invokes event handlers as
+ /// appropriate. Listeners can use the event data to respond to file changes, such as updating UI elements or
+ /// processing new log entries.
+ ///
private void FireChangeEvent ()
{
LogEventArgs args = new()
@@ -1905,14 +2111,21 @@ private void FireChangeEvent ()
/// Creates an for reading log entries from the specified stream using the provided
/// encoding options.
///
- /// If XML mode is enabled, the returned reader splits and parses XML log blocks according to the
- /// current XML log configuration. The caller is responsible for disposing the returned reader when
- /// finished.
- /// The input stream containing the log data to be read. The stream must be readable and positioned at the start of
- /// the log content.
- /// The encoding options to use when interpreting the log data from the stream.
- /// An instance for reading log entries from the specified stream. If XML mode is
- /// enabled, the reader parses XML log blocks; otherwise, it reads logs in the default format.
+ ///
+ /// If XML mode is enabled, the returned reader splits and parses XML log blocks according to the current XML log
+ /// configuration. The caller is responsible for disposing the returned reader when finished.
+ ///
+ ///
+ /// The input stream containing the log data to be read. The stream must be readable and positioned at the start of
+ /// the log content.
+ ///
+ ///
+ /// The encoding options to use when interpreting the log data from the stream.
+ ///
+ ///
+ /// An instance for reading log entries from the specified stream. If XML mode is
+ /// enabled, the reader parses XML log blocks; otherwise, it reads logs in the default format.
+ ///
private ILogStreamReader GetLogStreamReader (Stream stream, EncodingOptions encodingOptions)
{
var reader = CreateLogStreamReader(stream, encodingOptions);
@@ -1924,10 +2137,16 @@ private ILogStreamReader GetLogStreamReader (Stream stream, EncodingOptions enco
/// Creates an instance of an ILogStreamReader for reading log data from the specified stream using the provided
/// encoding options.
///
- /// The input stream containing the log data to be read. The stream must be readable and positioned at the start of
- /// the log data.
- /// The encoding options to use when interpreting the log data from the stream.
- /// An ILogStreamReader instance configured to read from the specified stream with the given encoding options.
+ ///
+ /// The input stream containing the log data to be read. The stream must be readable and positioned at the start of
+ /// the log data.
+ ///
+ ///
+ /// The encoding options to use when interpreting the log data from the stream.
+ ///
+ ///
+ /// An ILogStreamReader instance configured to read from the specified stream with the given encoding options.
+ ///
private ILogStreamReader CreateLogStreamReader (Stream stream, EncodingOptions encodingOptions)
{
return _readerType switch
@@ -1942,14 +2161,19 @@ private ILogStreamReader CreateLogStreamReader (Stream stream, EncodingOptions e
///
/// Attempts to read a single line from the specified log stream reader and applies optional preprocessing.
///
- /// If an IOException or NotSupportedException occurs during reading, the method logs a warning
- /// and treats the situation as end of stream. If a PreProcessColumnizer is set, the line is processed before being
- /// returned.
+ ///
+ /// If an IOException or NotSupportedException occurs during reading, the method logs a warning and treats the
+ /// situation as end of stream. If a PreProcessColumnizer is set, the line is processed before being returned.
+ ///
/// The log stream reader from which to read the next line. Cannot be null.
- /// The logical line number to associate with the line being read. Used for preprocessing.
+ ///
+ /// The logical line number to associate with the line being read. Used for preprocessing.
+ ///
/// The actual line number in the underlying data source. Used for preprocessing.
- /// When this method returns, contains the line that was read and optionally preprocessed, or null if the end of the
- /// stream is reached or an error occurs.
+ ///
+ /// When this method returns, contains the line that was read and optionally preprocessed, or null if the end of the
+ /// stream is reached or an error occurs.
+ ///
/// true if a line was successfully read and assigned to outLine; otherwise, false.
private bool ReadLine (ILogStreamReader reader, int lineNum, int realLineNum, out string outLine)
{
@@ -1990,15 +2214,23 @@ private bool ReadLine (ILogStreamReader reader, int lineNum, int realLineNum, ou
/// Attempts to read a single line from the specified log stream reader, returning both the line as a string and, if
/// available, as a memory buffer without additional allocations.
///
- /// If the reader implements memory-based access, this method avoids unnecessary string
- /// allocations by returning the line as a ReadOnlyMemory. Otherwise, it falls back to reading the line as a
- /// string only. The returned memory buffer is only valid until the next read operation on the reader.
+ ///
+ /// If the reader implements memory-based access, this method avoids unnecessary string allocations by returning the
+ /// line as a ReadOnlyMemory. Otherwise, it falls back to reading the line as a string only. The returned
+ /// memory buffer is only valid until the next read operation on the reader.
+ ///
/// The log stream reader from which to read the line. Must not be null.
- /// The zero-based logical line number to associate with the read operation. Used for preprocessing or context.
- /// The zero-based physical line number in the underlying data source. Used for preprocessing or context.
- /// A tuple containing a boolean indicating success, a read-only memory buffer containing the line if available, and
+ ///
+ /// The zero-based logical line number to associate with the read operation. Used for preprocessing or context.
+ ///
+ ///
+ /// The zero-based physical line number in the underlying data source. Used for preprocessing or context.
+ ///
+ ///
+ /// A tuple containing a boolean indicating success, a read-only memory buffer containing the line if available, and
/// the line as a string. If the reader supports memory-based access, the memory buffer is populated; otherwise, it
- /// is null.
+ /// is null.
+ ///
private (bool Success, ReadOnlyMemory LineMemory, bool wasDropped) ReadLineMemory (ILogStreamReaderMemory reader, int lineNum, int realLineNum)
{
if (reader is null)
@@ -2039,10 +2271,11 @@ private bool ReadLine (ILogStreamReader reader, int lineNum, int realLineNum, ou
/// Acquires an upgradeable read lock on the buffer list, waiting up to 10 seconds before blocking indefinitely if
/// the lock is not immediately available.
///
- /// This method ensures that the calling thread holds an upgradeable read lock on the buffer
- /// list. If the lock cannot be acquired within 10 seconds, a warning is logged and the method blocks until the lock
- /// becomes available. Use this method when a read lock is needed with the potential to upgrade to a write
- /// lock.
+ ///
+ /// This method ensures that the calling thread holds an upgradeable read lock on the buffer list. If the lock
+ /// cannot be acquired within 10 seconds, a warning is logged and the method blocks until the lock becomes
+ /// available. Use this method when a read lock is needed with the potential to upgrade to a write lock.
+ ///
private void AcquireBufferListUpgradeableReadLock ()
{
if (!_bufferListLock.TryEnterUpgradeableReadLock(TimeSpan.FromSeconds(10)))
@@ -2056,9 +2289,11 @@ private void AcquireBufferListUpgradeableReadLock ()
/// Acquires an upgradeable read lock on the dispose lock, waiting up to 10 seconds before blocking indefinitely if
/// the lock is not immediately available.
///
- /// This method ensures that the current thread holds an upgradeable read lock on the dispose
- /// lock, allowing for potential escalation to a write lock if needed. If the lock cannot be acquired within 10
- /// seconds, a warning is logged and the method blocks until the lock becomes available.
+ ///
+ /// This method ensures that the current thread holds an upgradeable read lock on the dispose lock, allowing for
+ /// potential escalation to a write lock if needed. If the lock cannot be acquired within 10 seconds, a warning is
+ /// logged and the method blocks until the lock becomes available.
+ ///
private void AcquireDisposeLockUpgradableReadLock ()
{
if (!_disposeLock.TryEnterUpgradeableReadLock(TimeSpan.FromSeconds(10)))
@@ -2072,11 +2307,12 @@ private void AcquireDisposeLockUpgradableReadLock ()
/// Acquires an upgradeable read lock on the LRU cache dictionary, waiting up to 10 seconds before blocking
/// indefinitely if the lock is not immediately available.
///
- /// This method ensures that the calling thread holds an upgradeable read lock on the LRU cache
- /// dictionary, allowing for safe read access and the potential to upgrade to a write lock if necessary. If the lock
- /// cannot be acquired within 10 seconds, a warning is logged and the method blocks until the lock becomes
- /// available. This approach helps prevent deadlocks and provides diagnostic information in case of lock
- /// contention.
+ ///
+ /// This method ensures that the calling thread holds an upgradeable read lock on the LRU cache dictionary, allowing
+ /// for safe read access and the potential to upgrade to a write lock if necessary. If the lock cannot be acquired
+ /// within 10 seconds, a warning is logged and the method blocks until the lock becomes available. This approach
+ /// helps prevent deadlocks and provides diagnostic information in case of lock contention.
+ ///
private void AcquireLRUCacheDictUpgradeableReadLock ()
{
if (!_lruCacheDictLock.TryEnterUpgradeableReadLock(TimeSpan.FromSeconds(10)))
@@ -2089,9 +2325,11 @@ private void AcquireLRUCacheDictUpgradeableReadLock ()
///
/// Acquires a read lock on the LRU cache dictionary to ensure thread-safe read access.
///
- /// If the read lock cannot be acquired within 10 seconds, a warning is logged and the method
- /// will block until the lock becomes available. Callers should ensure that this method is used in contexts where
- /// blocking is acceptable to avoid potential deadlocks or performance issues.
+ ///
+ /// If the read lock cannot be acquired within 10 seconds, a warning is logged and the method will block until the
+ /// lock becomes available. Callers should ensure that this method is used in contexts where blocking is acceptable
+ /// to avoid potential deadlocks or performance issues.
+ ///
private void AcquireLruCacheDictReaderLock ()
{
if (!_lruCacheDictLock.TryEnterReadLock(TimeSpan.FromSeconds(10)))
@@ -2104,9 +2342,10 @@ private void AcquireLruCacheDictReaderLock ()
///
/// Acquires a read lock on the dispose lock, blocking the calling thread until the lock is obtained.
///
- /// If the read lock cannot be acquired within 10 seconds, a warning is logged and the method
- /// will block until the lock becomes available. This method is intended to ensure thread-safe access during
- /// disposal operations.
+ ///
+ /// If the read lock cannot be acquired within 10 seconds, a warning is logged and the method will block until the
+ /// lock becomes available. This method is intended to ensure thread-safe access during disposal operations.
+ ///
private void AcquireDisposeReaderLock ()
{
if (!_disposeLock.TryEnterReadLock(TimeSpan.FromSeconds(10)))
@@ -2119,8 +2358,10 @@ private void AcquireDisposeReaderLock ()
///
/// Releases the writer lock held on the LRU cache dictionary, allowing other threads to acquire the lock.
///
- /// Call this method after completing operations that require exclusive access to the LRU cache
- /// dictionary. Failing to release the writer lock may result in deadlocks or reduced concurrency.
+ ///
+ /// Call this method after completing operations that require exclusive access to the LRU cache dictionary. Failing
+ /// to release the writer lock may result in deadlocks or reduced concurrency.
+ ///
private void ReleaseLRUCacheDictWriterLock ()
{
_lruCacheDictLock.ExitWriteLock();
@@ -2129,9 +2370,11 @@ private void ReleaseLRUCacheDictWriterLock ()
///
/// Releases the writer lock held for disposing resources.
///
- /// Call this method to exit the write lock acquired for resource disposal. This should be used
- /// in conjunction with the corresponding method that acquires the writer lock to ensure proper synchronization
- /// during disposal operations.
+ ///
+ /// Call this method to exit the write lock acquired for resource disposal. This should be used in conjunction with
+ /// the corresponding method that acquires the writer lock to ensure proper synchronization during disposal
+ /// operations.
+ ///
private void ReleaseDisposeWriterLock ()
{
_disposeLock.ExitWriteLock();
@@ -2140,9 +2383,10 @@ private void ReleaseDisposeWriterLock ()
///
/// Releases the read lock on the LRU cache dictionary to allow write access by other threads.
///
- /// Call this method after completing operations that require read access to the LRU cache
- /// dictionary. Failing to release the lock may result in deadlocks or prevent other threads from acquiring write
- /// access.
+ ///
+ /// Call this method after completing operations that require read access to the LRU cache dictionary. Failing to
+ /// release the lock may result in deadlocks or prevent other threads from acquiring write access.
+ ///
private void ReleaseLRUCacheDictReaderLock ()
{
_lruCacheDictLock.ExitReadLock();
@@ -2151,9 +2395,10 @@ private void ReleaseLRUCacheDictReaderLock ()
///
/// Releases a reader lock held for disposing resources, allowing other threads to acquire the lock as needed.
///
- /// Call this method to release the read lock previously acquired for resource disposal
- /// operations. Failing to release the lock may result in deadlocks or prevent other threads from accessing the
- /// protected resource.
+ ///
+ /// Call this method to release the read lock previously acquired for resource disposal operations. Failing to
+ /// release the lock may result in deadlocks or prevent other threads from accessing the protected resource.
+ ///
private void ReleaseDisposeReaderLock ()
{
_disposeLock.ExitReadLock();
@@ -2162,9 +2407,11 @@ private void ReleaseDisposeReaderLock ()
///
/// Releases the upgradeable read lock held on the LRU cache dictionary.
///
- /// Call this method to release the upgradeable read lock previously acquired on the LRU cache
- /// dictionary. Failing to release the lock may result in deadlocks or reduced concurrency. This method should be
- /// used in conjunction with the corresponding lock acquisition method to ensure proper synchronization.
+ ///
+ /// Call this method to release the upgradeable read lock previously acquired on the LRU cache dictionary. Failing
+ /// to release the lock may result in deadlocks or reduced concurrency. This method should be used in conjunction
+ /// with the corresponding lock acquisition method to ensure proper synchronization.
+ ///
private void ReleaseLRUCacheDictUpgradeableReadLock ()
{
_lruCacheDictLock.ExitUpgradeableReadLock();
@@ -2174,9 +2421,11 @@ private void ReleaseLRUCacheDictUpgradeableReadLock ()
/// Acquires the writer lock used to synchronize disposal operations, blocking the calling thread until the lock is
/// obtained.
///
- /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method
- /// waits indefinitely until the lock becomes available. Callers should ensure that holding the lock for extended
- /// periods does not cause deadlocks or performance issues.
+ ///
+ /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method waits indefinitely
+ /// until the lock becomes available. Callers should ensure that holding the lock for extended periods does not
+ /// cause deadlocks or performance issues.
+ ///
private void AcquireDisposeWriterLock ()
{
if (!_disposeLock.TryEnterWriteLock(TimeSpan.FromSeconds(10)))
@@ -2190,9 +2439,11 @@ private void AcquireDisposeWriterLock ()
/// Acquires an exclusive writer lock on the LRU cache dictionary, blocking if the lock is not immediately
/// available.
///
- /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method
- /// blocks until the lock becomes available. This method should be called before performing write operations on the
- /// LRU cache dictionary to ensure thread safety.
+ ///
+ /// If the writer lock cannot be acquired within 10 seconds, a warning is logged and the method blocks until the
+ /// lock becomes available. This method should be called before performing write operations on the LRU cache
+ /// dictionary to ensure thread safety.
+ ///
private void AcquireLruCacheDictWriterLock ()
{
if (!_lruCacheDictLock.TryEnterWriteLock(TimeSpan.FromSeconds(10)))
@@ -2206,8 +2457,10 @@ private void AcquireLruCacheDictWriterLock ()
/// Releases the upgradeable read lock on the buffer list, allowing other threads to acquire exclusive or read
/// access.
///
- /// Call this method after completing operations that required an upgradeable read lock on the
- /// buffer list. Failing to release the lock may result in deadlocks or reduced concurrency.
+ ///
+ /// Call this method after completing operations that required an upgradeable read lock on the buffer list. Failing
+ /// to release the lock may result in deadlocks or reduced concurrency.
+ ///
private void ReleaseBufferListUpgradeableReadLock ()
{
_bufferListLock.ExitUpgradeableReadLock();
@@ -2217,9 +2470,11 @@ private void ReleaseBufferListUpgradeableReadLock ()
/// Upgrades the buffer list lock from a reader lock to a writer lock, waiting up to 10 seconds before forcing the
/// upgrade if necessary.
///
- /// If the writer lock cannot be acquired within 10 seconds, the method logs a warning and then
- /// blocks until the writer lock is obtained. Call this method only when the current thread already holds a reader
- /// lock on the buffer list.
+ ///
+ /// If the writer lock cannot be acquired within 10 seconds, the method logs a warning and then blocks until the
+ /// writer lock is obtained. Call this method only when the current thread already holds a reader lock on the buffer
+ /// list.
+ ///
private void UpgradeBufferlistLockToWriterLock ()
{
if (!_bufferListLock.TryEnterWriteLock(TimeSpan.FromSeconds(10)))
@@ -2232,9 +2487,11 @@ private void UpgradeBufferlistLockToWriterLock ()
///
/// Upgrades the current dispose lock to a writer lock, blocking if necessary until the upgrade is successful.
///
- /// This method attempts to upgrade the dispose lock to a writer lock with a timeout. If the
- /// upgrade cannot be completed within the timeout period, it logs a warning and blocks until the writer lock is
- /// acquired. Call this method when exclusive access is required for disposal or resource modification.
+ ///
+ /// This method attempts to upgrade the dispose lock to a writer lock with a timeout. If the upgrade cannot be
+ /// completed within the timeout period, it logs a warning and blocks until the writer lock is acquired. Call this
+ /// method when exclusive access is required for disposal or resource modification.
+ ///
private void UpgradeDisposeLockToWriterLock ()
{
if (!_disposeLock.TryEnterWriteLock(TimeSpan.FromSeconds(10)))
@@ -2248,9 +2505,11 @@ private void UpgradeDisposeLockToWriterLock ()
/// Upgrades the lock on the LRU cache dictionary from a reader lock to a writer lock, waiting up to 10 seconds
/// before forcing the upgrade.
///
- /// If the writer lock cannot be acquired within 10 seconds, the method logs a warning and then
- /// blocks until the writer lock is available. Call this method only when it is necessary to perform write
- /// operations on the LRU cache dictionary after holding a reader lock.
+ ///
+ /// If the writer lock cannot be acquired within 10 seconds, the method logs a warning and then blocks until the
+ /// writer lock is available. Call this method only when it is necessary to perform write operations on the LRU
+ /// cache dictionary after holding a reader lock.
+ ///
private void UpgradeLRUCacheDicLockToWriterLock ()
{
if (!_lruCacheDictLock.TryEnterWriteLock(TimeSpan.FromSeconds(10)))
@@ -2263,8 +2522,10 @@ private void UpgradeLRUCacheDicLockToWriterLock ()
///
/// Downgrades the buffer list lock from write mode to allow other threads to acquire read access.
///
- /// Call this method after completing write operations to permit concurrent read access to the
- /// buffer list. The calling thread must hold the write lock before invoking this method.
+ ///
+ /// Call this method after completing write operations to permit concurrent read access to the buffer list. The
+ /// calling thread must hold the write lock before invoking this method.
+ ///
private void DowngradeBufferListLockFromWriterLock ()
{
_bufferListLock.ExitWriteLock();
@@ -2273,9 +2534,10 @@ private void DowngradeBufferListLockFromWriterLock ()
///
/// Downgrades the LRU cache lock from a writer lock, allowing other threads to acquire read access.
///
- /// Call this method after completing operations that require exclusive write access to the LRU
- /// cache, to permit concurrent read operations. The caller must hold the writer lock before invoking this
- /// method.
+ ///
+ /// Call this method after completing operations that require exclusive write access to the LRU cache, to permit
+ /// concurrent read operations. The caller must hold the writer lock before invoking this method.
+ ///
private void DowngradeLRUCacheLockFromWriterLock ()
{
_lruCacheDictLock.ExitWriteLock();
@@ -2284,9 +2546,11 @@ private void DowngradeLRUCacheLockFromWriterLock ()
///
/// Releases the writer lock on the dispose lock, downgrading from write access.
///
- /// Call this method to release write access to the dispose lock when a downgrade is required,
- /// such as when transitioning from exclusive to shared access. This method should only be called when the current
- /// thread holds the writer lock.
+ ///
+ /// Call this method to release write access to the dispose lock when a downgrade is required, such as when
+ /// transitioning from exclusive to shared access. This method should only be called when the current thread holds
+ /// the writer lock.
+ ///
private void DowngradeDisposeLockFromWriterLock ()
{
_disposeLock.ExitWriteLock();
@@ -2296,10 +2560,13 @@ private void DowngradeDisposeLockFromWriterLock ()
///
/// Outputs detailed information about the specified log buffer to the trace logger for debugging purposes.
///
- /// This method is only available in debug builds. It writes buffer details such as start line,
- /// line count, position, size, disposal state, and associated file to the trace log if trace logging is
- /// enabled.
- /// The log buffer whose information will be written to the trace output. Cannot be null.
+ ///
+ /// This method is only available in debug builds. It writes buffer details such as start line, line count,
+ /// position, size, disposal state, and associated file to the trace log if trace logging is enabled.
+ ///
+ ///
+ /// The log buffer whose information will be written to the trace output. Cannot be null.
+ ///
private static void DumpBufferInfos (LogBuffer buffer)
{
if (_logger.IsTraceEnabled)
@@ -2324,8 +2591,10 @@ private static void DumpBufferInfos (LogBuffer buffer)
///
/// Releases all resources used by the current instance of the class.
///
- /// Call this method when you are finished using the object to release unmanaged resources and
- /// perform other cleanup operations. After calling Dispose, the object should not be used.
+ ///
+ /// Call this method when you are finished using the object to release unmanaged resources and perform other cleanup
+ /// operations. After calling Dispose, the object should not be used.
+ ///
public void Dispose ()
{
Dispose(true);
@@ -2335,10 +2604,14 @@ public void Dispose ()
///
/// Releases the unmanaged resources used by the object and optionally releases the managed resources.
///
- /// This method is called by public Dispose methods and can be overridden to release additional
- /// resources in derived classes. When disposing is true, both managed and unmanaged resources should be released.
- /// When disposing is false, only unmanaged resources should be released.
- /// true to release both managed and unmanaged resources; false to release only unmanaged resources.
+ ///
+ /// This method is called by public Dispose methods and can be overridden to release additional resources in derived
+ /// classes. When disposing is true, both managed and unmanaged resources should be released. When disposing is
+ /// false, only unmanaged resources should be released.
+ ///
+ ///
+ /// true to release both managed and unmanaged resources; false to release only unmanaged resources.
+ ///
protected virtual void Dispose (bool disposing)
{
if (!_disposed)
@@ -2357,9 +2630,10 @@ protected virtual void Dispose (bool disposing)
/// Finalizes an instance of the LogfileReader class, releasing unmanaged resources before the object is reclaimed
/// by garbage collection.
///
- /// This destructor is called automatically by the garbage collector when the object is no longer
- /// accessible. It ensures that any unmanaged resources are properly released if Dispose was not called
- /// explicitly.
+ ///
+ /// This destructor is called automatically by the garbage collector when the object is no longer accessible. It
+ /// ensures that any unmanaged resources are properly released if Dispose was not called explicitly.
+ ///
//TODO: Seems that this can be deleted. Need to verify.
~LogfileReader ()
{
@@ -2373,8 +2647,10 @@ protected virtual void Dispose (bool disposing)
///
/// Raises the FileSizeChanged event to notify subscribers when the size of the log file changes.
///
- /// Derived classes can override this method to provide custom handling when the file size changes. This
- /// method is typically called after the file size has been updated.
+ ///
+ /// Derived classes can override this method to provide custom handling when the file size changes. This method is
+ /// typically called after the file size has been updated.
+ ///
/// An object that contains the event data associated with the file size change.
protected virtual void OnFileSizeChanged (LogEventArgs e)
{
@@ -2384,8 +2660,10 @@ protected virtual void OnFileSizeChanged (LogEventArgs e)
///
/// Raises the LoadFile event to notify subscribers that a file load operation has occurred.
///
- /// Override this method in a derived class to provide custom handling when a file is loaded.
- /// Calling the base implementation ensures that registered event handlers are invoked.
+ ///
+ /// Override this method in a derived class to provide custom handling when a file is loaded. Calling the base
+ /// implementation ensures that registered event handlers are invoked.
+ ///
/// An object that contains the event data for the file load operation.
protected virtual void OnLoadFile (LoadFileEventArgs e)
{
@@ -2395,8 +2673,10 @@ protected virtual void OnLoadFile (LoadFileEventArgs e)
///
/// Raises the LoadingStarted event to signal that a file loading operation has begun.
///
- /// Derived classes can override this method to provide custom handling when a loading operation
- /// starts. This method is typically called to notify subscribers that loading has commenced.
+ ///
+ /// Derived classes can override this method to provide custom handling when a loading operation starts. This method
+ /// is typically called to notify subscribers that loading has commenced.
+ ///
/// An object that contains the event data associated with the loading operation.
protected virtual void OnLoadingStarted (LoadFileEventArgs e)
{
@@ -2406,8 +2686,10 @@ protected virtual void OnLoadingStarted (LoadFileEventArgs e)
///
/// Raises the LoadingFinished event to signal that the loading process has completed.
///
- /// Override this method in a derived class to provide custom logic when loading is finished.
- /// This method is typically called after all loading operations are complete to notify subscribers.
+ ///
+ /// Override this method in a derived class to provide custom logic when loading is finished. This method is
+ /// typically called after all loading operations are complete to notify subscribers.
+ ///
protected virtual void OnLoadingFinished ()
{
LoadingFinished?.Invoke(this, EventArgs.Empty);
@@ -2416,8 +2698,10 @@ protected virtual void OnLoadingFinished ()
///
/// Raises the event that signals a file was not found.
///
- /// Override this method in a derived class to provide custom handling when a file is not found.
- /// This method invokes the associated event handlers, if any are subscribed.
+ ///
+ /// Override this method in a derived class to provide custom handling when a file is not found. This method invokes
+ /// the associated event handlers, if any are subscribed.
+ ///
protected virtual void OnFileNotFound ()
{
FileNotFound?.Invoke(this, EventArgs.Empty);
@@ -2426,8 +2710,10 @@ protected virtual void OnFileNotFound ()
///
/// Raises the Respawned event to notify subscribers that the object has respawned.
///
- /// Override this method in a derived class to provide custom logic when the object respawns.
- /// Always call the base implementation to ensure that the Respawned event is raised.
+ ///
+ /// Override this method in a derived class to provide custom logic when the object respawns. Always call the base
+ /// implementation to ensure that the Respawned event is raised.
+ ///
protected virtual void OnRespawned ()
{
_logger.Info(CultureInfo.InvariantCulture, "OnRespawned()");
diff --git a/src/LogExpert.Core/Entities/Bookmark.cs b/src/LogExpert.Core/Entities/Bookmark.cs
index 4ae563a3..2da5e7af 100644
--- a/src/LogExpert.Core/Entities/Bookmark.cs
+++ b/src/LogExpert.Core/Entities/Bookmark.cs
@@ -12,6 +12,7 @@ public class Bookmark
[JsonConstructor]
public Bookmark () { }
+ //TODO: Bookmarks Text should be Span or Memory
public Bookmark (int lineNum)
{
LineNum = lineNum;
@@ -26,6 +27,15 @@ public Bookmark (int lineNum, string comment)
Overlay = new BookmarkOverlay();
}
+ public static Bookmark CreateAutoGenerated (int lineNum, string comment, string sourceHighlightText)
+ {
+ return new Bookmark(lineNum, comment)
+ {
+ IsAutoGenerated = true,
+ SourceHighlightText = sourceHighlightText
+ };
+ }
+
#endregion
#region Properties
@@ -41,5 +51,19 @@ public Bookmark (int lineNum, string comment)
///
public Size OverlayOffset { get; set; }
+ ///
+ /// Indicates whether this bookmark was auto-generated by a highlight rule scan.
+ /// Auto-generated bookmarks are transient and not persisted to session files.
+ ///
+ [JsonIgnore]
+ public bool IsAutoGenerated { get; set; }
+
+ ///
+ /// The search text of the highlight entry that triggered this bookmark.
+ /// Used for display in the BookmarkWindow "Source" column.
+ ///
+ [JsonIgnore]
+ public string SourceHighlightText { get; set; }
+
#endregion
}
\ No newline at end of file
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 419c3aeb..074a9a5d 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -280,6 +280,15 @@ public static string BookmarkWindow_UI_DataGridColumn_HeaderText {
}
}
+ ///
+ /// Looks up a localized string similar to Source Highlight Trigger.
+ ///
+ public static string BookmarkWindow_UI_DataGridColumn_HeaderTextSource {
+ get {
+ return ResourceManager.GetString("BookmarkWindow_UI_DataGridColumn_HeaderTextSource", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bookmark comment:.
///
@@ -316,6 +325,24 @@ public static string BookmarkWindow_UI_ReallyRemoveBookmarkCommentsForSelectedLi
}
}
+ ///
+ /// Looks up a localized string similar to Auto.
+ ///
+ public static string BookmarkWindow_UI_SourceHighlightText_Auto {
+ get {
+ return ResourceManager.GetString("BookmarkWindow_UI_SourceHighlightText_Auto", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Manual.
+ ///
+ public static string BookmarkWindow_UI_SourceHighlightText_Manual {
+ get {
+ return ResourceManager.GetString("BookmarkWindow_UI_SourceHighlightText_Manual", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Bookmarks.
///
@@ -3315,6 +3342,33 @@ public static string LogWindow_UI_StatusLineText_FilterSearch_Filtering {
}
}
+ ///
+ /// Looks up a localized string similar to Scanning bookmarks....
+ ///
+ public static string LogWindow_UI_StatusLineText_ScanningBookmarks {
+ get {
+ return ResourceManager.GetString("LogWindow_UI_StatusLineText_ScanningBookmarks", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Scanning bookmarks finished / canceled!.
+ ///
+ public static string LogWindow_UI_StatusLineText_ScanningBookmarksEnded {
+ get {
+ return ResourceManager.GetString("LogWindow_UI_StatusLineText_ScanningBookmarksEnded", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Scanning bookmarks... {0}%.
+ ///
+ public static string LogWindow_UI_StatusLineText_ScanningBookmarksPct {
+ get {
+ return ResourceManager.GetString("LogWindow_UI_StatusLineText_ScanningBookmarksPct", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Searching... Press ESC to cancel..
///
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 351aae19..25039580 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -2148,4 +2148,22 @@ LogExpert neu starten, um die Änderungen zu übernehmen?
Einige Dateien konnten nicht migriert werden: {0}
+
+ Highlight-Trigger Quelle
+
+
+ Auto
+
+
+ Manual
+
+
+ Scanne Lesezeichen...
+
+
+ Scanne Lesezeichen... {0}%
+
+
+ Scanne Lesezeichen beendet / abgebrochen!
+
\ No newline at end of file
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 762a37d8..baae535d 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -2157,4 +2157,22 @@ Restart LogExpert to apply changes?
Some files could not be migrated: {0}
+
+ Source Highlight Trigger
+
+
+ Auto
+
+
+ Manual
+
+
+ Scanning bookmarks...
+
+
+ Scanning bookmarks... {0}%
+
+
+ Scanning bookmarks finished / canceled!
+
\ No newline at end of file
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 9206232a..2c106f32 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -2065,4 +2065,22 @@ YY[YY] = 年
某些文件无法迁移:{0}
+
+ 源高亮触发
+
+
+ 汽车
+
+
+ 手动的
+
+
+ 正在扫描书签...
+
+
+ 正在扫描书签...{0}%
+
+
+ 扫描书签已完成/取消!
+
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs
new file mode 100644
index 00000000..d3f2e570
--- /dev/null
+++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs
@@ -0,0 +1,273 @@
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Bookmark;
+using LogExpert.Core.Classes.Highlight;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Bookmark;
+
+[TestFixture]
+public class HighlightBookmarkScannerTests
+{
+
+ [SetUp]
+ public void SetUp ()
+ {
+ }
+
+ #region Helper
+
+ ///
+ /// Simple ILogLineMemory implementation for testing.
+ ///
+ private class TestLogLine : ILogLineMemory
+ {
+ private readonly string _text;
+
+ public TestLogLine (string text, int lineNumber)
+ {
+ _text = text;
+ LineNumber = lineNumber;
+ }
+
+ public ReadOnlyMemory FullLine => _text.AsMemory();
+ public ReadOnlyMemory Text => _text.AsMemory();
+ public int LineNumber { get; }
+ }
+
+ private static ILogLineMemory MakeLine (string text, int lineNum) => new TestLogLine(text, lineNum);
+
+ #endregion
+
+ [Test]
+ public void Scan_NoBookmarkEntries_ReturnsEmpty ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = false }
+ };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(3, i => MakeLine($"Line {i} ERROR", i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void Scan_SingleMatch_ReturnsOneBookmark ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = true, BookmarkComment = "Found error" }
+ };
+ var lines = new[] { "INFO starting", "ERROR something failed", "INFO done" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(3, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].LineNum, Is.EqualTo(1));
+ Assert.That(result[0].IsAutoGenerated, Is.True);
+ Assert.That(result[0].SourceHighlightText, Is.EqualTo("ERROR"));
+ Assert.That(result[0].Text, Is.EqualTo("Found error"));
+ }
+
+ [Test]
+ public void Scan_MultipleMatches_ReturnsMultipleBookmarks ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = true, BookmarkComment = "" }
+ };
+ var lines = new[] { "ERROR first", "INFO ok", "ERROR second" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(3, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result[0].LineNum, Is.EqualTo(0));
+ Assert.That(result[1].LineNum, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void Scan_NoMatches_ReturnsEmpty ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "FATAL", IsSetBookmark = true }
+ };
+ var lines = new[] { "INFO ok", "DEBUG trace" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(2, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void Scan_CaseInsensitiveMatch_Works ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "error", IsSetBookmark = true, IsCaseSensitive = false }
+ };
+ var lines = new[] { "ERROR something" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(1, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public void Scan_CaseSensitiveMatch_RespectsCase ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "error", IsSetBookmark = true, IsCaseSensitive = true }
+ };
+ var lines = new[] { "ERROR something" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(1, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void Scan_RegexMatch_Works ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERR\\w+", IsSetBookmark = true, IsRegex = true }
+ };
+ var lines = new[] { "ERROR something" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(1, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public void Scan_MultipleEntries_OnlyBookmarkEntriesUsed ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = true, BookmarkComment = "err" },
+ new() { SearchText = "WARN", IsSetBookmark = false }, // not a bookmark entry
+ new() { SearchText = "FATAL", IsSetBookmark = true, BookmarkComment = "fatal" }
+ };
+ var lines = new[] { "ERROR happened", "WARN ignored", "FATAL crash" };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(3, i => MakeLine(lines[i], i), entries, "test.log");
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result[0].LineNum, Is.EqualTo(0));
+ Assert.That(result[0].SourceHighlightText, Is.EqualTo("ERROR"));
+ Assert.That(result[1].LineNum, Is.EqualTo(2));
+ Assert.That(result[1].SourceHighlightText, Is.EqualTo("FATAL"));
+ }
+
+ [Test]
+ public void Scan_NullLine_Skipped ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = true }
+ };
+
+ // Act — line 1 returns null
+ var result = HighlightBookmarkScanner.Scan(3, i => i == 1 ? null : MakeLine("ERROR text", i), entries, "test.log");
+
+ // Assert — only lines 0 and 2 produce bookmarks
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result[0].LineNum, Is.EqualTo(0));
+ Assert.That(result[1].LineNum, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void Scan_CancellationRequested_ThrowsOperationCanceled ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = true }
+ };
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ _ = Assert.Throws(() => HighlightBookmarkScanner.Scan(100, i => MakeLine("ERROR text", i), entries, "test.log", cancellationToken: cts.Token));
+ }
+
+ [Test]
+ public void Scan_ReportsProgress ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "x", IsSetBookmark = true }
+ };
+ var reported = new List();
+ var progress = new Progress(reported.Add);
+
+ // Act — use synchronous progress to capture values
+ // Note: Progress posts to SynchronizationContext, so for test we use a simple impl
+ var syncProgress = new SyncProgress(reported);
+ _ = HighlightBookmarkScanner.Scan(3, i => MakeLine("x", i), entries, "test.log", 1, syncProgress, CancellationToken.None);
+
+ // Assert
+ Assert.That(reported, Is.EqualTo([0, 1, 2]));
+ }
+
+ [Test]
+ public void Scan_ZeroLines_ReturnsEmpty ()
+ {
+ // Arrange
+ var entries = new List
+ {
+ new() { SearchText = "ERROR", IsSetBookmark = true }
+ };
+
+ // Act
+ var result = HighlightBookmarkScanner.Scan(0, _ => null, entries, "test.log");
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ #region Helper classes
+
+ ///
+ /// Synchronous IProgress implementation for testing (avoids SynchronizationContext issues).
+ ///
+ private class SyncProgress (List values) : IProgress
+ {
+ private readonly List _values = values;
+
+ public void Report (T value) => _values.Add(value);
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs
new file mode 100644
index 00000000..831d94c9
--- /dev/null
+++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs
@@ -0,0 +1,746 @@
+using LogExpert.Core.Classes.Bookmark;
+using LogExpert.Core.Classes.Highlight;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Bookmark;
+
+[TestFixture]
+public class HighlightBookmarkTriggerTests
+{
+ #region GetHighlightActions Tests
+
+ [Test]
+ public void GetHighlightActions_WhenIsSetBookmarkTrue_ReturnsSetBookmarkTrue ()
+ {
+ // Arrange
+ var entry = new HighlightEntry
+ {
+ SearchText = "ERROR",
+ IsSetBookmark = true,
+ BookmarkComment = "Error found"
+ };
+ IList matchingList = [entry];
+
+ // Act — GetHighlightActions is a private static method in LogWindow.
+ // We replicate its logic here to test the contract.
+ var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
+
+ // Assert
+ Assert.That(setBookmark, Is.True);
+ Assert.That(bookmarkComment, Is.EqualTo("Error found"));
+ }
+
+ [Test]
+ public void GetHighlightActions_WhenIsSetBookmarkFalse_ReturnsSetBookmarkFalse ()
+ {
+ // Arrange
+ var entry = new HighlightEntry
+ {
+ SearchText = "INFO",
+ IsSetBookmark = false
+ };
+ IList matchingList = [entry];
+
+ // Act
+ var (_, _, setBookmark, _) = ExtractHighlightActions(matchingList);
+
+ // Assert
+ Assert.That(setBookmark, Is.False);
+ }
+
+ [Test]
+ public void GetHighlightActions_WhenMultipleEntriesWithBookmarks_ConcatenatesComments ()
+ {
+ // Arrange
+ var entry1 = new HighlightEntry
+ {
+ SearchText = "ERROR",
+ IsSetBookmark = true,
+ BookmarkComment = "First"
+ };
+
+ var entry2 = new HighlightEntry
+ {
+ SearchText = "WARN",
+ IsSetBookmark = true,
+ BookmarkComment = "Second"
+ };
+
+ IList matchingList = [entry1, entry2];
+
+ // Act
+ var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
+
+ // Assert
+ Assert.That(setBookmark, Is.True);
+ Assert.That(bookmarkComment, Is.EqualTo("First\r\nSecond"));
+ }
+
+ [Test]
+ public void GetHighlightActions_WhenEmptyList_ReturnsAllFalse ()
+ {
+ // Arrange
+ IList matchingList = [];
+
+ // Act
+ var (noLed, stopTail, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
+
+ // Assert
+ Assert.That(noLed, Is.False);
+ Assert.That(stopTail, Is.False);
+ Assert.That(setBookmark, Is.False);
+ Assert.That(bookmarkComment, Is.Empty);
+ }
+
+ [Test]
+ public void GetHighlightActions_WhenBookmarkCommentIsEmpty_ReturnsSetBookmarkTrueWithEmptyComment ()
+ {
+ // Arrange
+ var entry = new HighlightEntry
+ {
+ SearchText = "ERROR",
+ IsSetBookmark = true,
+ BookmarkComment = string.Empty
+ };
+ IList matchingList = [entry];
+
+ // Act
+ var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
+
+ // Assert
+ Assert.That(setBookmark, Is.True);
+ Assert.That(bookmarkComment, Is.Empty);
+ }
+
+ [Test]
+ public void GetHighlightActions_WhenBookmarkCommentIsNull_ReturnsSetBookmarkTrueWithEmptyComment ()
+ {
+ // Arrange
+ var entry = new HighlightEntry
+ {
+ SearchText = "ERROR",
+ IsSetBookmark = true,
+ BookmarkComment = null
+ };
+ IList matchingList = [entry];
+
+ // Act
+ var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
+
+ // Assert
+ Assert.That(setBookmark, Is.True);
+ Assert.That(bookmarkComment, Is.Empty);
+ }
+
+ ///
+ /// Replicates the logic from LogWindow.GetHighlightActions (private static). This must stay in sync with the actual
+ /// implementation. If the implementation is refactored to be testable directly, remove this helper.
+ ///
+ private static (bool NoLed, bool StopTail, bool SetBookmark, string BookmarkComment) ExtractHighlightActions (IList matchingList)
+ {
+ var noLed = false;
+ var stopTail = false;
+ var setBookmark = false;
+ var bookmarkComment = string.Empty;
+
+ foreach (var entry in matchingList)
+ {
+ if (entry.IsLedSwitch)
+ {
+ noLed = true;
+ }
+
+ if (entry.IsSetBookmark)
+ {
+ setBookmark = true;
+ if (!string.IsNullOrEmpty(entry.BookmarkComment))
+ {
+ bookmarkComment += entry.BookmarkComment + "\r\n";
+ }
+ }
+
+ if (entry.IsStopTail)
+ {
+ stopTail = true;
+ }
+ }
+
+ bookmarkComment = bookmarkComment.TrimEnd(['\r', '\n']);
+
+ return (noLed, stopTail, setBookmark, bookmarkComment);
+ }
+
+ #endregion
+
+ #region BookmarkDataProvider Tests
+
+ [Test]
+ public void ConvertToManualBookmark_ConvertsAutoToManual ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(42, "auto", "ERROR"));
+
+ // Act
+ var result = provider.ConvertToManualBookmark(42);
+
+ // Assert
+ Assert.That(result, Is.True);
+ var bookmark = provider.GetBookmarkForLine(42);
+ Assert.That(bookmark.IsAutoGenerated, Is.False);
+ Assert.That(bookmark.SourceHighlightText, Is.Null);
+ }
+
+ [Test]
+ public void ConvertToManualBookmark_ManualBookmark_ReturnsFalse ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(42, "manual"));
+
+ // Act
+ var result = provider.ConvertToManualBookmark(42);
+
+ // Assert
+ Assert.That(result, Is.False);
+ }
+
+ [Test]
+ public void ConvertToManualBookmark_NoBookmark_ReturnsFalse ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+
+ // Act
+ var result = provider.ConvertToManualBookmark(42);
+
+ // Assert
+ Assert.That(result, Is.False);
+ }
+
+ [Test]
+ public void ConvertToManualBookmark_SurvivesRemoveAutoGenerated ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(10, "auto1", "ERROR"));
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(20, "auto2", "WARN"));
+ _ = provider.ConvertToManualBookmark(10); // convert line 10 to manual
+
+ // Act
+ provider.RemoveAutoGeneratedBookmarks();
+
+ // Assert
+ Assert.That(provider.IsBookmarkAtLine(10), Is.True, "Converted bookmark should survive");
+ Assert.That(provider.IsBookmarkAtLine(20), Is.False, "Non-converted auto bookmark should be removed");
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmark_IsBookmarkAtLineReturnsTrue ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ var bookmark = new Core.Entities.Bookmark(42, "Test comment");
+
+ // Act
+ provider.AddBookmark(bookmark);
+
+ // Assert
+ Assert.That(provider.IsBookmarkAtLine(42), Is.True);
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmark_GetBookmarkReturnsCorrectBookmark ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ var bookmark = new Core.Entities.Bookmark(42, "Test comment");
+
+ // Act
+ provider.AddBookmark(bookmark);
+ var result = provider.GetBookmarkForLine(42);
+
+ // Assert
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.LineNum, Is.EqualTo(42));
+ Assert.That(result.Text, Is.EqualTo("Test comment"));
+ }
+
+ [Test]
+ public void BookmarkDataProvider_RemoveBookmark_IsBookmarkAtLineReturnsFalse ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(42, "Test"));
+
+ // Act
+ provider.RemoveBookmarkForLine(42);
+
+ // Assert
+ Assert.That(provider.IsBookmarkAtLine(42), Is.False);
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmark_FiresBookmarkAddedEvent ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ var eventFired = false;
+ provider.BookmarkAdded += (_, _) => eventFired = true;
+
+ // Act
+ provider.AddBookmark(new Core.Entities.Bookmark(42));
+
+ // Assert
+ Assert.That(eventFired, Is.True);
+ }
+
+ #endregion
+
+ #region HighlightEntry Serialization Tests
+
+ [Test]
+ public void HighlightEntry_Clone_PreservesIsSetBookmark ()
+ {
+ // Arrange
+ var entry = new HighlightEntry
+ {
+ SearchText = "ERROR",
+ IsSetBookmark = true,
+ BookmarkComment = "Critical error"
+ };
+
+ // Act
+ var clone = entry.Clone();
+
+ // Assert
+ Assert.That(((HighlightEntry)clone).IsSetBookmark, Is.True);
+ Assert.That(((HighlightEntry)clone).BookmarkComment, Is.EqualTo("Critical error"));
+ }
+
+ [Test]
+ public void HighlightEntry_Clone_PreservesIsSetBookmarkWhenFalse ()
+ {
+ // Arrange
+ var entry = new HighlightEntry
+ {
+ SearchText = "INFO",
+ IsSetBookmark = false,
+ BookmarkComment = string.Empty
+ };
+
+ // Act
+ var clone = entry.Clone();
+
+ // Assert
+ Assert.That(((HighlightEntry)clone).IsSetBookmark, Is.False);
+ }
+
+ #endregion
+
+ #region Closure Regression Tests
+
+ ///
+ /// Demonstrates the closure-over-loop-variable bug pattern. This test proves the bug exists when loop variables are
+ /// captured directly. If this test fails in the future, the C# language has changed loop variable capture semantics
+ /// for `for` loops.
+ ///
+ [Test]
+ public void ClosureBug_ForLoopVariable_CapturedByReference_DemonstratesBug ()
+ {
+ // Arrange
+ var capturedValues = new List();
+ var tasks = new List();
+
+ // Act — simulate the buggy pattern
+ for (var i = 0; i < 5; i++)
+ {
+ // -- BUG PATTERN: capturing `i` directly
+ tasks.Add(Task.Run(() =>
+ {
+ lock (capturedValues)
+ {
+ capturedValues.Add(i);
+ }
+ }));
+ }
+
+ Task.WaitAll([.. tasks]);
+
+ // Assert — at least one value should be wrong (likely all are 5)
+ // The key observation: NOT all values 0..4 are present
+ var hasAllExpected = capturedValues.Order().SequenceEqual([0, 1, 2, 3, 4]);
+ Assert.That(hasAllExpected, Is.False,
+ "If this fails, the closure-over-loop-variable issue no longer applies to `for` loops in this C# version.");
+ }
+
+ ///
+ /// Demonstrates the correct pattern — capturing the loop variable in a local. This is the pattern that must be
+ /// applied in CheckFilterAndHighlight().
+ ///
+ [Test]
+ public void ClosureFix_LocalCapture_AllValuesCorrect ()
+ {
+ // Arrange
+ var capturedValues = new List();
+ var tasks = new List();
+
+ // Act — correct pattern: capture in local variable
+ for (var i = 0; i < 5; i++)
+ {
+ var captured = i; // FIX: local capture
+ tasks.Add(Task.Run(() =>
+ {
+ lock (capturedValues)
+ {
+ capturedValues.Add(captured);
+ }
+ }));
+ }
+
+ Task.WaitAll([.. tasks]);
+
+ // Assert — all values 0..4 must be present
+ capturedValues.Sort();
+ Assert.That(capturedValues, Is.EquivalentTo([0, 1, 2, 3, 4]));
+ }
+
+ [Test]
+ public void Bookmark_DefaultConstructor_IsAutoGeneratedIsFalse ()
+ {
+ // Arrange & Act
+ var bookmark = new Core.Entities.Bookmark();
+
+ // Assert
+ Assert.That(bookmark.IsAutoGenerated, Is.False);
+ Assert.That(bookmark.SourceHighlightText, Is.Null);
+ }
+
+ [Test]
+ public void Bookmark_LineNumConstructor_IsAutoGeneratedIsFalse ()
+ {
+ // Arrange & Act
+ var bookmark = new Core.Entities.Bookmark(42);
+
+ // Assert
+ Assert.That(bookmark.IsAutoGenerated, Is.False);
+ Assert.That(bookmark.SourceHighlightText, Is.Null);
+ }
+
+ [Test]
+ public void Bookmark_CommentConstructor_IsAutoGeneratedIsFalse ()
+ {
+ // Arrange & Act
+ var bookmark = new Core.Entities.Bookmark(42, "test comment");
+
+ // Assert
+ Assert.That(bookmark.IsAutoGenerated, Is.False);
+ Assert.That(bookmark.SourceHighlightText, Is.Null);
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmarks_AddsAllAndFiresEventOnce ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ var eventCount = 0;
+ provider.BookmarkAdded += (_, _) => eventCount++;
+
+ var bookmarks = new[]
+ {
+ new Core.Entities.Bookmark(10),
+ new Core.Entities.Bookmark(20),
+ new Core.Entities.Bookmark(30)
+ };
+
+ // Act
+ var added = provider.AddBookmarks(bookmarks);
+
+ // Assert
+ Assert.That(added, Is.EqualTo(3));
+ Assert.That(provider.Bookmarks.Count, Is.EqualTo(3));
+ Assert.That(eventCount, Is.EqualTo(1), "BookmarkAdded should fire exactly once for a batch add");
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmarks_SkipsDuplicates ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(20));
+
+ var eventCount = 0;
+ provider.BookmarkAdded += (_, _) => eventCount++;
+
+ var bookmarks = new[]
+ {
+ new Core.Entities.Bookmark(10),
+ new Core.Entities.Bookmark(20), // duplicate — should be skipped
+ new Core.Entities.Bookmark(30)
+ };
+
+ // Act
+ var added = provider.AddBookmarks(bookmarks);
+
+ // Assert
+ Assert.That(added, Is.EqualTo(2));
+ Assert.That(provider.Bookmarks.Count, Is.EqualTo(3));
+ Assert.That(provider.IsBookmarkAtLine(10), Is.True);
+ Assert.That(provider.IsBookmarkAtLine(20), Is.True);
+ Assert.That(provider.IsBookmarkAtLine(30), Is.True);
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmarks_EmptyList_DoesNotFireEvent ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ var eventFired = false;
+ provider.BookmarkAdded += (_, _) => eventFired = true;
+
+ // Act
+ var added = provider.AddBookmarks([]);
+
+ // Assert
+ Assert.That(added, Is.EqualTo(0));
+ Assert.That(eventFired, Is.False, "BookmarkAdded should not fire when no bookmarks were added");
+ }
+
+ [Test]
+ public void BookmarkDataProvider_AddBookmarks_AllDuplicates_DoesNotFireEvent ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(10));
+
+ var eventCount = 0;
+ provider.BookmarkAdded += (_, _) => eventCount++;
+
+ // Act
+ var added = provider.AddBookmarks([new Core.Entities.Bookmark(10)]);
+
+ // Assert
+ Assert.That(added, Is.EqualTo(0));
+ Assert.That(eventCount, Is.EqualTo(0), "BookmarkAdded should not fire when all bookmarks are duplicates");
+ }
+
+ [Test]
+ public void Bookmark_CreateAutoGenerated_SetsPropertiesCorrectly ()
+ {
+ // Arrange & Act
+ var bookmark = Core.Entities.Bookmark.CreateAutoGenerated(100, "Error found", "ERROR");
+
+ // Assert
+ Assert.That(bookmark.IsAutoGenerated, Is.True);
+ Assert.That(bookmark.SourceHighlightText, Is.EqualTo("ERROR"));
+ Assert.That(bookmark.LineNum, Is.EqualTo(100));
+ Assert.That(bookmark.Text, Is.EqualTo("Error found"));
+ }
+
+ [Test]
+ public void Bookmark_CreateAutoGenerated_WithEmptyComment_Works ()
+ {
+ // Arrange & Act
+ var bookmark = Core.Entities.Bookmark.CreateAutoGenerated(50, string.Empty, "WARN");
+
+ // Assert
+ Assert.That(bookmark.IsAutoGenerated, Is.True);
+ Assert.That(bookmark.SourceHighlightText, Is.EqualTo("WARN"));
+ Assert.That(bookmark.Text, Is.Empty);
+ }
+
+ [Test]
+ public void RemoveAutoGeneratedBookmarks_RemovesOnlyAutoGenerated ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(10, "manual"));
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(20, "auto1", "ERROR"));
+ provider.AddBookmark(new Core.Entities.Bookmark(30, "manual2"));
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(40, "auto2", "WARN"));
+
+ // Act
+ provider.RemoveAutoGeneratedBookmarks();
+
+ // Assert
+ Assert.That(provider.Bookmarks.Count, Is.EqualTo(2));
+ Assert.That(provider.IsBookmarkAtLine(10), Is.True);
+ Assert.That(provider.IsBookmarkAtLine(20), Is.False);
+ Assert.That(provider.IsBookmarkAtLine(30), Is.True);
+ Assert.That(provider.IsBookmarkAtLine(40), Is.False);
+ }
+
+ [Test]
+ public void RemoveAutoGeneratedBookmarks_NoAutoGenerated_IsNoOp ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(10, "manual"));
+ provider.AddBookmark(new Core.Entities.Bookmark(20, "manual2"));
+ var eventFired = false;
+ provider.BookmarkRemoved += (_, _) => eventFired = true;
+
+ // Act
+ provider.RemoveAutoGeneratedBookmarks();
+
+ // Assert
+ Assert.That(provider.Bookmarks.Count, Is.EqualTo(2));
+ Assert.That(eventFired, Is.False);
+ }
+
+ [Test]
+ public void RemoveAutoGeneratedBookmarks_EmptyProvider_IsNoOp ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ var eventFired = false;
+ provider.BookmarkRemoved += (_, _) => eventFired = true;
+
+ // Act
+ provider.RemoveAutoGeneratedBookmarks();
+
+ // Assert
+ Assert.That(provider.Bookmarks.Count, Is.EqualTo(0));
+ Assert.That(eventFired, Is.False);
+ }
+
+ [Test]
+ public void RemoveAutoGeneratedBookmarks_AllAutoGenerated_RemovesAll ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(10, "auto1", "ERROR"));
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(20, "auto2", "WARN"));
+
+ // Act
+ provider.RemoveAutoGeneratedBookmarks();
+
+ // Assert
+ Assert.That(provider.Bookmarks.Count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void RemoveAutoGeneratedBookmarks_FiresBookmarkRemovedEvent ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(10, "auto", "ERROR"));
+ var eventFired = false;
+ provider.BookmarkRemoved += (_, _) => eventFired = true;
+
+ // Act
+ provider.RemoveAutoGeneratedBookmarks();
+
+ // Assert
+ Assert.That(eventFired, Is.True);
+ }
+
+ #endregion
+
+ #region Bookmark Serialization Tests
+
+ [Test]
+ public void Bookmark_JsonSerialize_ExcludesAutoGeneratedProperties ()
+ {
+ // Arrange
+ var bookmark = Core.Entities.Bookmark.CreateAutoGenerated(42, "Error", "ERROR");
+
+ // Act
+ var json = Newtonsoft.Json.JsonConvert.SerializeObject(bookmark);
+
+ // Assert
+ Assert.That(json, Does.Not.Contain("IsAutoGenerated"));
+ Assert.That(json, Does.Not.Contain("SourceHighlightText"));
+ }
+
+ [Test]
+ public void Bookmark_JsonDeserialize_DefaultsToManual ()
+ {
+ // Arrange
+ var json = "{\"LineNum\":42,\"Text\":\"Error\"}";
+
+ // Act
+ var bookmark = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
+
+ // Assert
+ Assert.That(bookmark.IsAutoGenerated, Is.False);
+ Assert.That(bookmark.SourceHighlightText, Is.Null);
+ }
+
+ #endregion
+
+ #region Persistence Exclusion Tests
+
+ [Test]
+ public void PersistenceExclusion_AutoBookmarks_FilteredFromSerialization ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(new Core.Entities.Bookmark(10, "manual"));
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(20, "auto", "ERROR"));
+ provider.AddBookmark(new Core.Entities.Bookmark(30, "manual2"));
+
+ // Act — simulate GetPersistenceData filtering
+ SortedList manualBookmarks = [];
+ foreach (var kvp in provider.BookmarkList)
+ {
+ if (!kvp.Value.IsAutoGenerated)
+ {
+ manualBookmarks.Add(kvp.Key, kvp.Value);
+ }
+ }
+
+ // Assert
+ Assert.That(manualBookmarks.Count, Is.EqualTo(2));
+ Assert.That(manualBookmarks.ContainsKey(10), Is.True);
+ Assert.That(manualBookmarks.ContainsKey(20), Is.False);
+ Assert.That(manualBookmarks.ContainsKey(30), Is.True);
+ }
+
+ [Test]
+ public void PersistenceExclusion_ConvertedBookmarks_IncludedInSerialization ()
+ {
+ // Arrange
+ var provider = new BookmarkDataProvider();
+ provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(10, "auto", "ERROR"));
+ _ = provider.ConvertToManualBookmark(10);
+
+ // Act — simulate GetPersistenceData filtering
+ SortedList manualBookmarks = [];
+ foreach (var kvp in provider.BookmarkList)
+ {
+ if (!kvp.Value.IsAutoGenerated)
+ {
+ manualBookmarks.Add(kvp.Key, kvp.Value);
+ }
+ }
+
+ // Assert — converted bookmark should be included
+ Assert.That(manualBookmarks.Count, Is.EqualTo(1));
+ Assert.That(manualBookmarks.ContainsKey(10), Is.True);
+ }
+
+ [Test]
+ public void PersistenceExclusion_RoundTrip_ExcludesAutoGenerated ()
+ {
+ // Arrange
+ var auto = Core.Entities.Bookmark.CreateAutoGenerated(42, "auto bookmark", "ERROR");
+ var manual = new Core.Entities.Bookmark(100, "manual bookmark");
+
+ // Act — serialize both
+ var autoJson = Newtonsoft.Json.JsonConvert.SerializeObject(auto);
+ var manualJson = Newtonsoft.Json.JsonConvert.SerializeObject(manual);
+
+ // Deserialize
+ var autoDeserialized = Newtonsoft.Json.JsonConvert.DeserializeObject(autoJson);
+ var manualDeserialized = Newtonsoft.Json.JsonConvert.DeserializeObject(manualJson);
+
+ // Assert — both deserialize as manual (IsAutoGenerated is not persisted)
+ Assert.That(autoDeserialized.IsAutoGenerated, Is.False);
+ Assert.That(manualDeserialized.IsAutoGenerated, Is.False);
+ Assert.That(autoDeserialized.SourceHighlightText, Is.Null);
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/LogExpert.Tests/HighlightBookmarkTriggerTests.cs b/src/LogExpert.Tests/HighlightBookmarkTriggerTests.cs
deleted file mode 100644
index a50730a9..00000000
--- a/src/LogExpert.Tests/HighlightBookmarkTriggerTests.cs
+++ /dev/null
@@ -1,351 +0,0 @@
-using LogExpert.Core.Classes.Bookmark;
-using LogExpert.Core.Classes.Highlight;
-using LogExpert.Core.Entities;
-
-using NUnit.Framework;
-
-namespace LogExpert.Tests;
-
-[TestFixture]
-public class HighlightBookmarkTriggerTests
-{
- #region GetHighlightActions Tests
-
- [Test]
- public void GetHighlightActions_WhenIsSetBookmarkTrue_ReturnsSetBookmarkTrue ()
- {
- // Arrange
- var entry = new HighlightEntry
- {
- SearchText = "ERROR",
- IsSetBookmark = true,
- BookmarkComment = "Error found"
- };
- IList matchingList = [entry];
-
- // Act — GetHighlightActions is a private static method in LogWindow.
- // We replicate its logic here to test the contract.
- var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
-
- // Assert
- Assert.That(setBookmark, Is.True);
- Assert.That(bookmarkComment, Is.EqualTo("Error found"));
- }
-
- [Test]
- public void GetHighlightActions_WhenIsSetBookmarkFalse_ReturnsSetBookmarkFalse ()
- {
- // Arrange
- var entry = new HighlightEntry
- {
- SearchText = "INFO",
- IsSetBookmark = false
- };
- IList matchingList = [entry];
-
- // Act
- var (_, _, setBookmark, _) = ExtractHighlightActions(matchingList);
-
- // Assert
- Assert.That(setBookmark, Is.False);
- }
-
- [Test]
- public void GetHighlightActions_WhenMultipleEntriesWithBookmarks_ConcatenatesComments ()
- {
- // Arrange
- var entry1 = new HighlightEntry
- {
- SearchText = "ERROR",
- IsSetBookmark = true,
- BookmarkComment = "First"
- };
-
- var entry2 = new HighlightEntry
- {
- SearchText = "WARN",
- IsSetBookmark = true,
- BookmarkComment = "Second"
- };
-
- IList matchingList = [entry1, entry2];
-
- // Act
- var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
-
- // Assert
- Assert.That(setBookmark, Is.True);
- Assert.That(bookmarkComment, Is.EqualTo("First\r\nSecond"));
- }
-
- [Test]
- public void GetHighlightActions_WhenEmptyList_ReturnsAllFalse ()
- {
- // Arrange
- IList matchingList = [];
-
- // Act
- var (noLed, stopTail, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
-
- // Assert
- Assert.That(noLed, Is.False);
- Assert.That(stopTail, Is.False);
- Assert.That(setBookmark, Is.False);
- Assert.That(bookmarkComment, Is.Empty);
- }
-
- [Test]
- public void GetHighlightActions_WhenBookmarkCommentIsEmpty_ReturnsSetBookmarkTrueWithEmptyComment ()
- {
- // Arrange
- var entry = new HighlightEntry
- {
- SearchText = "ERROR",
- IsSetBookmark = true,
- BookmarkComment = string.Empty
- };
- IList matchingList = [entry];
-
- // Act
- var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
-
- // Assert
- Assert.That(setBookmark, Is.True);
- Assert.That(bookmarkComment, Is.Empty);
- }
-
- [Test]
- public void GetHighlightActions_WhenBookmarkCommentIsNull_ReturnsSetBookmarkTrueWithEmptyComment ()
- {
- // Arrange
- var entry = new HighlightEntry
- {
- SearchText = "ERROR",
- IsSetBookmark = true,
- BookmarkComment = null
- };
- IList matchingList = [entry];
-
- // Act
- var (_, _, setBookmark, bookmarkComment) = ExtractHighlightActions(matchingList);
-
- // Assert
- Assert.That(setBookmark, Is.True);
- Assert.That(bookmarkComment, Is.Empty);
- }
-
- ///
- /// Replicates the logic from LogWindow.GetHighlightActions (private static).
- /// This must stay in sync with the actual implementation.
- /// If the implementation is refactored to be testable directly, remove this helper.
- ///
- private static (bool NoLed, bool StopTail, bool SetBookmark, string BookmarkComment) ExtractHighlightActions (IList matchingList)
- {
- var noLed = false;
- var stopTail = false;
- var setBookmark = false;
- var bookmarkComment = string.Empty;
-
- foreach (var entry in matchingList)
- {
- if (entry.IsLedSwitch)
- {
- noLed = true;
- }
-
- if (entry.IsSetBookmark)
- {
- setBookmark = true;
- if (!string.IsNullOrEmpty(entry.BookmarkComment))
- {
- bookmarkComment += entry.BookmarkComment + "\r\n";
- }
- }
-
- if (entry.IsStopTail)
- {
- stopTail = true;
- }
- }
-
- bookmarkComment = bookmarkComment.TrimEnd(['\r', '\n']);
-
- return (noLed, stopTail, setBookmark, bookmarkComment);
- }
-
- #endregion
-
- #region BookmarkDataProvider Tests
-
- [Test]
- public void BookmarkDataProvider_AddBookmark_IsBookmarkAtLineReturnsTrue ()
- {
- // Arrange
- var provider = new BookmarkDataProvider();
- var bookmark = new Bookmark(42, "Test comment");
-
- // Act
- provider.AddBookmark(bookmark);
-
- // Assert
- Assert.That(provider.IsBookmarkAtLine(42), Is.True);
- }
-
- [Test]
- public void BookmarkDataProvider_AddBookmark_GetBookmarkReturnsCorrectBookmark ()
- {
- // Arrange
- var provider = new BookmarkDataProvider();
- var bookmark = new Bookmark(42, "Test comment");
-
- // Act
- provider.AddBookmark(bookmark);
- var result = provider.GetBookmarkForLine(42);
-
- // Assert
- Assert.That(result, Is.Not.Null);
- Assert.That(result.LineNum, Is.EqualTo(42));
- Assert.That(result.Text, Is.EqualTo("Test comment"));
- }
-
- [Test]
- public void BookmarkDataProvider_RemoveBookmark_IsBookmarkAtLineReturnsFalse ()
- {
- // Arrange
- var provider = new BookmarkDataProvider();
- provider.AddBookmark(new Bookmark(42, "Test"));
-
- // Act
- provider.RemoveBookmarkForLine(42);
-
- // Assert
- Assert.That(provider.IsBookmarkAtLine(42), Is.False);
- }
-
- [Test]
- public void BookmarkDataProvider_AddBookmark_FiresBookmarkAddedEvent ()
- {
- // Arrange
- var provider = new BookmarkDataProvider();
- var eventFired = false;
- provider.BookmarkAdded += (_, _) => eventFired = true;
-
- // Act
- provider.AddBookmark(new Bookmark(42));
-
- // Assert
- Assert.That(eventFired, Is.True);
- }
-
- #endregion
-
- #region HighlightEntry Serialization Tests
-
- [Test]
- public void HighlightEntry_Clone_PreservesIsSetBookmark ()
- {
- // Arrange
- var entry = new HighlightEntry
- {
- SearchText = "ERROR",
- IsSetBookmark = true,
- BookmarkComment = "Critical error"
- };
-
- // Act
- var clone = entry.Clone();
-
- // Assert
- Assert.That(((HighlightEntry)clone).IsSetBookmark, Is.True);
- Assert.That(((HighlightEntry)clone).BookmarkComment, Is.EqualTo("Critical error"));
- }
-
- [Test]
- public void HighlightEntry_Clone_PreservesIsSetBookmarkWhenFalse ()
- {
- // Arrange
- var entry = new HighlightEntry
- {
- SearchText = "INFO",
- IsSetBookmark = false,
- BookmarkComment = string.Empty
- };
-
- // Act
- var clone = entry.Clone();
-
- // Assert
- Assert.That(((HighlightEntry)clone).IsSetBookmark, Is.False);
- }
-
- #endregion
-
- #region Closure Regression Tests
-
- ///
- /// Demonstrates the closure-over-loop-variable bug pattern.
- /// This test proves the bug exists when loop variables are captured directly.
- /// If this test fails in the future, the C# language has changed loop variable capture semantics for `for` loops.
- ///
- [Test]
- public void ClosureBug_ForLoopVariable_CapturedByReference_DemonstratesBug ()
- {
- // Arrange
- var capturedValues = new List();
- var tasks = new List();
-
- // Act — simulate the buggy pattern
- for (var i = 0; i < 5; i++)
- {
- // -- BUG PATTERN: capturing `i` directly
- tasks.Add(Task.Run(() =>
- {
- lock (capturedValues)
- {
- capturedValues.Add(i);
- }
- }));
- }
-
- Task.WaitAll([.. tasks]);
-
- // Assert — at least one value should be wrong (likely all are 5)
- // The key observation: NOT all values 0..4 are present
- var hasAllExpected = capturedValues.Order().SequenceEqual([0, 1, 2, 3, 4]);
- Assert.That(hasAllExpected, Is.False,
- "If this fails, the closure-over-loop-variable issue no longer applies to `for` loops in this C# version.");
- }
-
- ///
- /// Demonstrates the correct pattern — capturing the loop variable in a local.
- /// This is the pattern that must be applied in CheckFilterAndHighlight().
- ///
- [Test]
- public void ClosureFix_LocalCapture_AllValuesCorrect ()
- {
- // Arrange
- var capturedValues = new List();
- var tasks = new List();
-
- // Act — correct pattern: capture in local variable
- for (var i = 0; i < 5; i++)
- {
- var captured = i; // FIX: local capture
- tasks.Add(Task.Run(() =>
- {
- lock (capturedValues)
- {
- capturedValues.Add(captured);
- }
- }));
- }
-
- Task.WaitAll([.. tasks]);
-
- // Assert — all values 0..4 must be present
- capturedValues.Sort();
- Assert.That(capturedValues, Is.EquivalentTo([0, 1, 2, 3, 4]));
- }
-
- #endregion
-}
\ No newline at end of file
diff --git a/src/LogExpert.UI/Controls/BufferedDataGridView.Designer.cs b/src/LogExpert.UI/Controls/BufferedDataGridView.Designer.cs
index f4908a0f..d3717714 100644
--- a/src/LogExpert.UI/Controls/BufferedDataGridView.Designer.cs
+++ b/src/LogExpert.UI/Controls/BufferedDataGridView.Designer.cs
@@ -1,30 +1,16 @@
-namespace LogExpert.Dialogs;
+namespace LogExpert.UI.Controls;
partial class BufferedDataGridView
{
- ///
+ ///
/// Required designer variable.
///
private System.ComponentModel.IContainer components = null;
- ///
- /// Clean up any resources being used.
- ///
- /// true if managed resources should be disposed; otherwise, false.
- protected override void Dispose(bool disposing)
- {
- if (disposing && (components != null))
- {
- components.Dispose();
- }
- base.Dispose(disposing);
- }
-
#region Component Designer generated code
- ///
- /// Required method for Designer support - do not modify
- /// the contents of this method with the code editor.
+ ///
+ /// Required method for Designer support - do not modify the contents of this method with the code editor.
///
private void InitializeComponent()
{
diff --git a/src/LogExpert.UI/Controls/BufferedDataGridView.cs b/src/LogExpert.UI/Controls/BufferedDataGridView.cs
index 8aa2df6f..5ef1f004 100644
--- a/src/LogExpert.UI/Controls/BufferedDataGridView.cs
+++ b/src/LogExpert.UI/Controls/BufferedDataGridView.cs
@@ -4,11 +4,10 @@
using LogExpert.Core.Entities;
using LogExpert.Core.EventArguments;
-using LogExpert.UI.Controls;
using NLog;
-namespace LogExpert.Dialogs;
+namespace LogExpert.UI.Controls;
[SupportedOSPlatform("windows")]
internal partial class BufferedDataGridView : DataGridView
@@ -16,17 +15,35 @@ internal partial class BufferedDataGridView : DataGridView
#region Fields
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
- private readonly Brush _brush;
- private readonly Color _bubbleColor = Color.FromArgb(160, 250, 250, 0); //yellow
- private readonly Font _font = new("Arial", 10);
+ private static Color BubbleColor =>
+ Application.IsDarkModeEnabled
+ ? Color.FromArgb(160, 80, 80, 0) // muted yellow on dark
+ : Color.FromArgb(160, 250, 250, 0); // bright yellow on light
- private readonly SortedList _overlayList = [];
+ private static Color TextColor =>
+ Application.IsDarkModeEnabled
+ ? Color.FromArgb(200, 180, 200, 255) // light blue on dark
+ : Color.FromArgb(200, 0, 0, 90); // dark blue on light
- private readonly Pen _pen;
- private readonly Brush _textBrush = new SolidBrush(Color.FromArgb(200, 0, 0, 90)); //dark blue
+ private readonly Font _font = new("Segoe UI", 9.75f);
+ private Pen? _pen;
+ private Brush? _brush;
+ private Brush? _textBrush;
+ private Color _currentBubbleColor;
+ private Color _currentTextColor;
- private BookmarkOverlay _draggedOverlay;
+ private readonly StringFormat _format = new()
+ {
+ LineAlignment = StringAlignment.Center,
+ Alignment = StringAlignment.Near
+ };
+
+ private readonly Lock _overlayLock = new();
+ private readonly List _overlayStaging = [];
+ private BookmarkOverlay[] _overlaySnapshot = [];
+
+ private BookmarkOverlay? _draggedOverlay;
private Point _dragStartPoint;
private bool _isDrag;
private Size _oldOverlayOffset;
@@ -37,9 +54,6 @@ internal partial class BufferedDataGridView : DataGridView
public BufferedDataGridView ()
{
- _pen = new Pen(_bubbleColor, (float)3.0);
- _brush = new SolidBrush(_bubbleColor);
-
InitializeComponent();
DoubleBuffered = true;
VirtualMode = true;
@@ -55,13 +69,6 @@ public BufferedDataGridView ()
#region Properties
- /*
- public Graphics Buffer
- {
- get { return this.myBuffer.Graphics; }
- }
- */
-
[DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
public ContextMenuStrip EditModeMenuStrip { get; set; }
@@ -74,16 +81,74 @@ public Graphics Buffer
public void AddOverlay (BookmarkOverlay overlay)
{
- lock (_overlayList)
+ lock (_overlayLock)
+ {
+ _overlayStaging.Add(overlay);
+ }
+ }
+
+ ///
+ /// Atomically captures all staged overlays and clears the staging list. Call this once per paint cycle before
+ /// drawing.
+ ///
+ private BookmarkOverlay[] SwapOverlaySnapshot ()
+ {
+ lock (_overlayLock)
{
- _overlayList.Add(overlay.Position.Y, overlay);
+ _overlaySnapshot = [.. _overlayStaging];
+ _overlayStaging.Clear();
+
+ return _overlaySnapshot;
+ }
+ }
+
+ ///
+ /// Ensures GDI+ drawing resources match the current color mode.
+ /// Called at the start of each paint cycle.
+ ///
+ private void EnsureDrawingResources ()
+ {
+ var bubbleColor = BubbleColor;
+ var textColor = TextColor;
+
+ if (bubbleColor == _currentBubbleColor
+ && textColor == _currentTextColor
+ && _pen is not null)
+ {
+ return;
}
+
+ _pen?.Dispose();
+ _brush?.Dispose();
+ _textBrush?.Dispose();
+
+ _currentBubbleColor = bubbleColor;
+ _currentTextColor = textColor;
+
+ _pen = new Pen(_currentBubbleColor, 3.0f);
+ _brush = new SolidBrush(_currentBubbleColor);
+ _textBrush = new SolidBrush(_currentTextColor);
}
#endregion
#region Overrides
+ protected override void Dispose (bool disposing)
+ {
+ if (disposing)
+ {
+ components?.Dispose();
+ _brush?.Dispose();
+ _pen?.Dispose();
+ _textBrush?.Dispose();
+ _font?.Dispose();
+ _format?.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
protected override void OnPaint (PaintEventArgs e)
{
try
@@ -99,8 +164,87 @@ protected override void OnPaint (PaintEventArgs e)
}
catch (Exception ex)
{
- _logger.Error(ex);
+ _logger.Error($"Overlay painting failed, falling back to base paint. {ex}");
+
+ try
+ {
+ base.OnPaint(e);
+ }
+ catch (InvalidOperationException innerEx)
+ {
+ _logger.Error($"Base paint also failed. {innerEx}");
+ }
+ }
+ }
+
+ private void PaintOverlays (PaintEventArgs e)
+ {
+ EnsureDrawingResources();
+
+ // Let the base DataGridView paint into its own double buffer first.
+ base.OnPaint(e);
+
+ // Atomically capture and clear staged overlays. No lock held after this.
+ var overlays = SwapOverlaySnapshot();
+
+ if (overlays.Length == 0)
+ {
+ return;
+ }
+
+ // Save the original clip and set up overlay clipping area.
+ var originalClip = e.Graphics.Clip;
+
+ e.Graphics.SetClip(DisplayRectangle, CombineMode.Replace);
+
+ // Exclude column headers from overlay drawing area.
+ Rectangle rectTableHeader = new(
+ DisplayRectangle.X,
+ DisplayRectangle.Y,
+ DisplayRectangle.Width,
+ ColumnHeadersHeight);
+
+ e.Graphics.SetClip(rectTableHeader, CombineMode.Exclude);
+
+ foreach (var overlay in overlays)
+ {
+ var textSize = e.Graphics.MeasureString(overlay.Bookmark.Text, _font, 300);
+
+ Rectangle rectBubble = new(
+ overlay.Position,
+ new Size((int)textSize.Width,
+ (int)textSize.Height));
+
+ rectBubble.Offset(60, -(rectBubble.Height + 40));
+ rectBubble.Inflate(3, 3);
+ rectBubble.Location += overlay.Bookmark.OverlayOffset;
+ overlay.BubbleRect = rectBubble;
+
+ // Temporarily extend clip to include the bubble area.
+ e.Graphics.SetClip(rectBubble, CombineMode.Union);
+ e.Graphics.SetClip(rectTableHeader, CombineMode.Exclude);
+
+ RectangleF textRect = new(
+ rectBubble.X,
+ rectBubble.Y,
+ rectBubble.Width,
+ rectBubble.Height);
+
+ e.Graphics.FillRectangle(_brush, rectBubble);
+ e.Graphics.DrawLine(
+ _pen,
+ overlay.Position,
+ new Point(rectBubble.X, rectBubble.Y + rectBubble.Height));
+ e.Graphics.DrawString(overlay.Bookmark.Text, _font, _textBrush, textRect, _format);
+
+ if (_logger.IsDebugEnabled)
+ {
+ _logger.Debug($"### PaintOverlays: {e.Graphics.ClipBounds.Left}, {e.Graphics.ClipBounds.Top}, {e.Graphics.ClipBounds.Width}, {e.Graphics.ClipBounds.Height}");
+ }
}
+
+ // Restore original clip region.
+ e.Graphics.Clip = originalClip;
}
protected override void OnEditingControlShowing (DataGridViewEditingControlShowingEventArgs e)
@@ -159,7 +303,7 @@ protected override void OnMouseUp (MouseEventArgs e)
protected override void OnMouseMove (MouseEventArgs e)
{
- if (_isDrag)
+ if (_isDrag && _draggedOverlay is not null)
{
Cursor = Cursors.Hand;
Size offset = new(e.X - _dragStartPoint.X, e.Y - _dragStartPoint.Y);
@@ -190,86 +334,33 @@ protected override void OnMouseDoubleClick (MouseEventArgs e)
}
}
- #endregion
-
- #region Private Methods
-
- private BookmarkOverlay GetOverlayForPosition (Point pos)
+ protected override void OnMouseLeave (EventArgs e)
{
- lock (_overlayList)
+ if (!_isDrag)
{
- foreach (var overlay in _overlayList.Values)
- {
- if (overlay.BubbleRect.Contains(pos))
- {
- return overlay;
- }
- }
+ Cursor = Cursors.Default;
}
- return null;
+ base.OnMouseLeave(e);
}
- private void PaintOverlays (PaintEventArgs e)
- {
- var currentContext = BufferedGraphicsManager.Current;
-
- using var myBuffer = currentContext.Allocate(e.Graphics, ClientRectangle);
- lock (_overlayList)
- {
- _overlayList.Clear();
- }
-
- myBuffer.Graphics.SetClip(ClientRectangle, CombineMode.Union);
- e.Graphics.SetClip(ClientRectangle, CombineMode.Union);
-
- PaintEventArgs args = new(myBuffer.Graphics, e.ClipRectangle);
-
- base.OnPaint(args);
-
- StringFormat format = new()
- {
- LineAlignment = StringAlignment.Center,
- Alignment = StringAlignment.Near
- };
-
- myBuffer.Graphics.SetClip(DisplayRectangle, CombineMode.Intersect);
+ #endregion
- // Remove Columnheader from Clippingarea
- Rectangle rectTableHeader = new(DisplayRectangle.X, DisplayRectangle.Y, DisplayRectangle.Width, ColumnHeadersHeight);
- myBuffer.Graphics.SetClip(rectTableHeader, CombineMode.Exclude);
+ #region Private Methods
- //e.Graphics.SetClip(rect, CombineMode.Union);
+ private BookmarkOverlay GetOverlayForPosition (Point pos)
+ {
+ var overlays = _overlaySnapshot;
- lock (_overlayList)
+ foreach (var overlay in overlays)
{
- foreach (var overlay in _overlayList.Values)
+ if (overlay.BubbleRect.Contains(pos))
{
- var textSize = myBuffer.Graphics.MeasureString(overlay.Bookmark.Text, _font, 300);
-
- Rectangle rectBubble = new(overlay.Position, new Size((int)textSize.Width, (int)textSize.Height));
- rectBubble.Offset(60, -(rectBubble.Height + 40));
- rectBubble.Inflate(3, 3);
- rectBubble.Location += overlay.Bookmark.OverlayOffset;
- overlay.BubbleRect = rectBubble;
- myBuffer.Graphics.SetClip(rectBubble, CombineMode.Union); // Bubble to clip
- myBuffer.Graphics.SetClip(rectTableHeader, CombineMode.Exclude);
- e.Graphics.SetClip(rectBubble, CombineMode.Union);
-
- RectangleF textRect = new(rectBubble.X, rectBubble.Y, rectBubble.Width, rectBubble.Height);
- myBuffer.Graphics.FillRectangle(_brush, rectBubble);
- //myBuffer.Graphics.DrawLine(_pen, overlay.Position, new Point(rect.X, rect.Y + rect.Height / 2));
- myBuffer.Graphics.DrawLine(_pen, overlay.Position, new Point(rectBubble.X, rectBubble.Y + rectBubble.Height));
- myBuffer.Graphics.DrawString(overlay.Bookmark.Text, _font, _textBrush, textRect, format);
-
- if (_logger.IsDebugEnabled)
- {
- _logger.Debug($"### PaintOverlays: {myBuffer.Graphics.ClipBounds.Left},{myBuffer.Graphics.ClipBounds.Top},{myBuffer.Graphics.ClipBounds.Width},{myBuffer.Graphics.ClipBounds.Height}");
- }
+ return overlay;
}
}
- myBuffer.Render(e.Graphics);
+ return null;
}
#endregion
diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
index 5039c726..73c388be 100644
--- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
@@ -72,8 +72,11 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL
private readonly EventWaitHandle _logEventArgsEvent = new AutoResetEvent(false);
private readonly List _logEventArgsList = [];
+
private readonly Task _logEventHandlerTask;
+
//private readonly Thread _logEventHandlerThread;
+
private readonly Image _panelCloseButtonImage;
private readonly Image _panelOpenButtonImage;
@@ -87,7 +90,9 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL
private readonly Lock _tempHighlightEntryListLock = new();
private readonly Task _timeShiftSyncTask;
- private readonly CancellationTokenSource cts = new();
+
+ private readonly CancellationTokenSource _cts = new();
+ private CancellationTokenSource _highlightBookmarkScanCts;
//private readonly Thread _timeShiftSyncThread;
private readonly EventWaitHandle _timeShiftSyncTimerEvent = new ManualResetEvent(false);
@@ -99,6 +104,12 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL
private ColumnCache _columnCache = new();
+ private readonly StringFormat _format = new()
+ {
+ LineAlignment = StringAlignment.Center,
+ Alignment = StringAlignment.Center
+ };
+
//List currentHilightEntryList = new List();
private HighlightGroup _currentHighlightGroup = new();
@@ -222,9 +233,9 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b
splitContainerLogWindow.Panel2Collapsed = true;
advancedFilterSplitContainer.SplitterDistance = FILTER_ADVANCED_SPLITTER_DISTANCE;
- _timeShiftSyncTask = Task.Factory.StartNew(SyncTimestampDisplayWorker, cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
+ _timeShiftSyncTask = Task.Factory.StartNew(SyncTimestampDisplayWorker, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
- _logEventHandlerTask = Task.Factory.StartNew(LogEventWorker, cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
+ _logEventHandlerTask = Task.Factory.StartNew(LogEventWorker, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
//this.filterUpdateThread = new Thread(new ThreadStart(this.FilterUpdateWorker));
//this.filterUpdateThread.Start();
@@ -319,6 +330,13 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b
[DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
public Color BookmarkColor { get; set; } = Color.FromArgb(165, 200, 225);
+ ///
+ /// Color used to paint the bookmark marker in column 0 for auto-generated (highlight-triggered) bookmarks. Uses a
+ /// lighter/more desaturated shade to distinguish from manual bookmarks.
+ ///
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
+ public Color AutoBookmarkColor { get; set; } = Color.FromArgb(180, 210, 180);
+
public ILogLineMemoryColumnizer CurrentColumnizer
{
get;
@@ -675,8 +693,6 @@ private void OnButtonSizeChanged (object sender, EventArgs e)
private Action, List, List> FilterFxAction;
//private delegate void FilterFx(FilterParams filterParams, List filterResultLines, List lastFilterResultLines, List filterHitList);
- private delegate void UpdateProgressBarFx (int lineNum);
-
private delegate void SetColumnizerFx (ILogLineMemoryColumnizer columnizer);
private delegate void WriteFilterToTabFinishedFx (FilterPipe pipe, string namePrefix, PersistenceData persistenceData);
@@ -871,6 +887,8 @@ private void OnLogFileReaderFinishedLoading (object sender, EventArgs e)
}
HandleChangedFilterList();
+
+ _ = Invoke(new MethodInvoker(RunHighlightBookmarkScan));
}
_reloadMemento = null;
@@ -2914,12 +2932,12 @@ private void LoadingFinished ()
private void LogEventWorker ()
{
Thread.CurrentThread.Name = "LogEventWorker";
- while (!cts.Token.IsCancellationRequested)
+ while (!_cts.Token.IsCancellationRequested)
{
//_logger.Debug($"Waiting for signal");
_ = _logEventArgsEvent.WaitOne();
//_logger.Debug($"Wakeup signal received.");
- while (!cts.Token.IsCancellationRequested)
+ while (!_cts.Token.IsCancellationRequested)
{
LogEventArgs e;
//var lastLineCount = 0;
@@ -2975,7 +2993,7 @@ private void LogEventWorker ()
private void StopLogEventWorkerThread ()
{
_ = _logEventArgsEvent.Set();
- cts.Cancel();
+ _cts.Cancel();
//_logEventHandlerThread.Abort();
//_logEventHandlerThread.Join();
}
@@ -3119,7 +3137,7 @@ private void CheckFilterAndHighlight (LogEventArgs e)
//pipeFx.BeginInvoke(i, null, null);
ProcessFilterPipes(i);
- var matchingList = FindMatchingHilightEntries(line);
+ var matchingList = FindMatchingHighlightEntries(line);
LaunchHighlightPlugins(matchingList, i);
var (suppressLed, stopTail, setBookmark, bookmarkComment) = GetHighlightActions(matchingList);
if (setBookmark)
@@ -3170,7 +3188,7 @@ private void CheckFilterAndHighlight (LogEventArgs e)
var line = _logFileReader.GetLogLineMemory(i);
if (line != null)
{
- var matchingList = FindMatchingHilightEntries(line);
+ var matchingList = FindMatchingHighlightEntries(line);
LaunchHighlightPlugins(matchingList, i);
var (suppressLed, stopTail, setBookmark, bookmarkComment) = GetHighlightActions(matchingList);
if (setBookmark)
@@ -3521,7 +3539,7 @@ private void PaintHighlightedCell (DataGridViewCellPaintingEventArgs e, Highligh
}
///
- /// Builds a list of HilightMatchEntry objects. A HilightMatchEntry spans over a region that is painted with the
+ /// Builds a list of HighlightMatchEntry objects. A HighlightMatchEntry spans over a region that is painted with the
/// same foreground and background colors. All regions which don't match a word-mode entry will be painted with the
/// colors of a default entry (groundEntry). This is either the first matching non-word-mode highlight entry or a
/// black-on-white default (if no matching entry was found).
@@ -3643,9 +3661,9 @@ private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValueMe
}
///
- /// Returns all HilightEntry entries which matches the given line
+ /// Returns all HighlightEntry entries which matches the given line
///
- private IList FindMatchingHilightEntries (ITextValueMemory line)
+ private IList FindMatchingHighlightEntries (ITextValueMemory line)
{
IList resultList = [];
if (line != null)
@@ -3746,7 +3764,7 @@ private void StopTimestampSyncThread ()
//_timeShiftSyncWakeupEvent.Set();
//_timeShiftSyncThread.Abort();
//_timeShiftSyncThread.Join();
- cts.Cancel();
+ _cts.Cancel();
}
[SupportedOSPlatform("windows")]
@@ -6275,9 +6293,19 @@ public void SavePersistenceData (bool force)
public PersistenceData GetPersistenceData ()
{
+ // Filter out auto-generated bookmarks — they are transient and will be re-generated on load
+ SortedList manualBookmarks = [];
+ foreach (var kvp in _bookmarkProvider.BookmarkList)
+ {
+ if (!kvp.Value.IsAutoGenerated)
+ {
+ manualBookmarks.Add(kvp.Key, kvp.Value);
+ }
+ }
+
PersistenceData persistenceData = new()
{
- BookmarkList = _bookmarkProvider.BookmarkList,
+ BookmarkList = manualBookmarks,
RowHeightList = _rowHeightList,
MultiFile = IsMultiFile,
MultiFilePattern = _multiFileOptions.FormatPattern,
@@ -6340,6 +6368,7 @@ public void Close (bool dontAsk)
public void CloseLogWindow ()
{
+ CancelHighlightBookmarkScan();
StopTimespreadThread();
StopTimestampSyncThread();
StopLogEventWorkerThread();
@@ -6521,26 +6550,20 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi
// = new Rectangle(e.CellBounds.Left + 2, e.CellBounds.Top + 2, 6, 6);
var rect = e.CellBounds;
rect.Inflate(-2, -2);
- using var brush = new SolidBrush(BookmarkColor);
- e.Graphics.FillRectangle(brush, rect);
-
var bookmark = _bookmarkProvider.GetBookmarkForLine(rowIndex);
+ var bookmarkColor = bookmark.IsAutoGenerated ? AutoBookmarkColor : BookmarkColor;
+ using var brush = new SolidBrush(bookmarkColor);
+ e.Graphics.FillRectangle(brush, rect);
if (bookmark.Text.Length > 0)
{
- StringFormat format = new()
- {
- LineAlignment = StringAlignment.Center,
- Alignment = StringAlignment.Center
- };
-
//Todo Add this as a Settings Option
var fontName = isFilteredGridView ? FONT_VERDANA : FONT_COURIER_NEW;
var stringToDraw = isFilteredGridView ? "!" : "i";
using var brush2 = new SolidBrush(Color.FromArgb(255, 190, 100, 0)); //dark orange
using var font = new Font(fontName, Preferences.FontSize, FontStyle.Bold);
- e.Graphics.DrawString(stringToDraw, font, brush2, new RectangleF(rect.Left, rect.Top, rect.Width, rect.Height), format);
+ e.Graphics.DrawString(stringToDraw, font, brush2, new RectangleF(rect.Left, rect.Top, rect.Width, rect.Height), _format);
}
}
}
@@ -6795,8 +6818,11 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e)
_shouldCancel = true;
}
+ CancelHighlightBookmarkScan();
FireCancelHandlers();
RemoveAllSearchHighlightEntries();
+
+
break;
}
case Keys.E when (e.Modifiers & Keys.Control) == Keys.Control:
@@ -6985,23 +7011,30 @@ public void ToggleBookmark (int lineNum)
{
var bookmark = _bookmarkProvider.GetBookmarkForLine(lineNum);
- if (!string.IsNullOrEmpty(bookmark.Text))
+ // If it's an auto-generated bookmark, convert to manual instead of removing
+ if (bookmark.IsAutoGenerated)
{
- if (MessageBox.Show(Resources.LogWindow_UI_ToggleBookmark_ThereCommentAttachedRemoveIt, Resources.LogExpert_Common_UI_Title_LogExpert, MessageBoxButtons.YesNo) == DialogResult.No)
+ _ = _bookmarkProvider.ConvertToManualBookmark(lineNum);
+ }
+ else
+ {
+ if (!string.IsNullOrEmpty(bookmark.Text))
{
- return;
+ if (MessageBox.Show(Resources.LogWindow_UI_ToggleBookmark_ThereCommentAttachedRemoveIt, Resources.LogExpert_Common_UI_Title_LogExpert, MessageBoxButtons.YesNo) == DialogResult.No)
+ {
+ return;
+ }
}
- }
- _bookmarkProvider.RemoveBookmarkForLine(lineNum);
+ _bookmarkProvider.RemoveBookmarkForLine(lineNum);
+ }
}
else
{
_bookmarkProvider.AddBookmark(new Bookmark(lineNum));
}
- dataGridView.Refresh();
- filterGridView.Refresh();
+ RefreshAllGrids();
OnBookmarkAdded();
}
@@ -7030,13 +7063,200 @@ public void SetBookmarkFromTrigger (int lineNum, string comment)
if (_bookmarkProvider.IsBookmarkAtLine(lineNum))
{
+ var existing = _bookmarkProvider.GetBookmarkForLine(lineNum);
+
+ // Don't overwrite manual bookmarks with auto-generated ones
+ if (!existing.IsAutoGenerated)
+ {
+ return;
+ }
+
_bookmarkProvider.RemoveBookmarkForLine(lineNum);
}
- _bookmarkProvider.AddBookmark(new Bookmark(lineNum, comment));
+ _bookmarkProvider.AddBookmark(Bookmark.CreateAutoGenerated(lineNum, comment, GetSourceHighlightTextForLine(lineNum)));
OnBookmarkAdded();
}
+ ///
+ /// Returns the SearchText of the first matching highlight entry with IsSetBookmark for the given line. Used to set
+ /// SourceHighlightText on trigger-created bookmarks.
+ ///
+ private string GetSourceHighlightTextForLine (int lineNum)
+ {
+ var line = _logFileReader.GetLogLineMemory(lineNum);
+
+ if (line == null)
+ {
+ return string.Empty;
+ }
+
+ var matchingList = FindMatchingHighlightEntries(line);
+
+ foreach (var entry in matchingList)
+ {
+ if (entry.IsSetBookmark)
+ {
+ return entry.SearchText;
+ }
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Cancels any in-progress highlight bookmark scan, removes existing auto-generated bookmarks, and starts a new
+ /// background scan based on the current highlight group.
+ ///
+ private void RunHighlightBookmarkScan ()
+ {
+ // Guard: don't scan during loading or if reader is not available
+ if (_isLoading || _logFileReader == null)
+ {
+ return;
+ }
+
+ // Cancel any in-progress scan
+ CancelHighlightBookmarkScan();
+
+ // Step 1: Remove previous auto-generated bookmarks
+ _bookmarkProvider.RemoveAutoGeneratedBookmarks();
+ RefreshAllGrids();
+
+ // Step 2: Get current highlight entries (snapshot under lock)
+ List entries;
+ lock (_currentHighlightGroupLock)
+ {
+ entries = [.. _currentHighlightGroup.HighlightEntryList];
+ }
+
+ // Step 3: Early exit if no entries have IsSetBookmark
+ if (!entries.Any(e => e.IsSetBookmark))
+ {
+ return;
+ }
+
+ // Step 4: Start background scan
+ var cts = new CancellationTokenSource();
+ _highlightBookmarkScanCts = cts;
+ var lineCount = _logFileReader.LineCount;
+ var fileName = FileName;
+
+ StatusLineText(Resources.LogWindow_UI_StatusLineText_ScanningBookmarks);
+ _progressEventArgs.MinValue = 0;
+ _progressEventArgs.MaxValue = lineCount;
+ _progressEventArgs.Value = 0;
+ _progressEventArgs.Visible = true;
+ SendProgressBarUpdate();
+
+ var progress = new Progress(OnHighlightBookmarkScanProgress);
+ _ = Task.Run(() => ExecuteHighlightBookmarkScan(lineCount, entries, fileName, progress, cts));
+ }
+
+ private void ExecuteHighlightBookmarkScan (int lineCount, List entries, string fileName, IProgress progress, CancellationTokenSource cts)
+ {
+ using (cts)
+ {
+ try
+ {
+ var bookmarks = HighlightBookmarkScanner.Scan(lineCount, _logFileReader.GetLogLineMemory, entries, fileName, PROGRESS_BAR_MODULO, progress, cts.Token);
+
+ // Marshal bookmark additions to UI thread
+ if (!cts.Token.IsCancellationRequested && IsHandleCreated && !IsDisposed)
+ {
+ _ = BeginInvoke(() =>
+ {
+ _ = _bookmarkProvider.AddBookmarks(bookmarks);
+
+ RefreshAllGrids();
+
+ _progressEventArgs.Visible = false;
+ SendProgressBarUpdate();
+ StatusLineText(string.Empty);
+ });
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Scan was cancelled — clean up on UI thread
+ if (IsHandleCreated && !IsDisposed)
+ {
+ _ = BeginInvoke(() =>
+ {
+ _progressEventArgs.Visible = false;
+ SendProgressBarUpdate();
+ StatusLineText(string.Empty);
+ });
+ }
+ }
+ finally
+ {
+ // Only dispose if this is still the active CTS
+ if (_highlightBookmarkScanCts == cts)
+ {
+ _highlightBookmarkScanCts = null;
+ }
+ }
+ }
+ }
+
+ private void OnHighlightBookmarkScanProgress (int currentLine)
+ {
+ try
+ {
+ if (_highlightBookmarkScanCts is not { } cts)
+ {
+ return;
+ }
+
+ if (cts.Token.IsCancellationRequested)
+ {
+ StatusLineText(Resources.LogWindow_UI_StatusLineText_ScanningBookmarksEnded);
+ return;
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ // CTS was disposed after task completed — progress callback is stale
+ return;
+ }
+
+ if (IsHandleCreated && !IsDisposed)
+ {
+ _ = BeginInvoke(() =>
+ {
+ _progressEventArgs.Value = currentLine;
+ SendProgressBarUpdate();
+
+ var lineCount = _logFileReader?.LineCount ?? 0;
+ if (lineCount > 0)
+ {
+ var pct = (int)((long)currentLine * 100 / lineCount);
+ StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_ScanningBookmarksPct, pct));
+ }
+ });
+ }
+ }
+
+ ///
+ /// Cancels any currently running highlight bookmark scan.
+ ///
+ private void CancelHighlightBookmarkScan ()
+ {
+ var cts = _highlightBookmarkScanCts;
+ if (cts != null)
+ {
+ try
+ {
+ cts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Already disposed — ignore
+ }
+ }
+ }
+
public void JumpNextBookmark ()
{
if (_bookmarkProvider.Bookmarks.Count > 0)
@@ -7809,18 +8029,17 @@ public void ImportBookmarkList ()
var bookmarkAdded = false;
foreach (var b in newBookmarks.Values)
{
- if (!_bookmarkProvider.BookmarkList.ContainsKey(b.LineNum))
- {
- _bookmarkProvider.BookmarkList.Add(b.LineNum, b);
- bookmarkAdded = true; // refresh the list only once at the end
- }
- else
+ if (_bookmarkProvider.BookmarkList.TryGetValue(b.LineNum, out Bookmark? existingBookmark))
{
- var existingBookmark = _bookmarkProvider.BookmarkList[b.LineNum];
// replace existing bookmark for that line, preserving the overlay
existingBookmark.Text = b.Text;
OnBookmarkTextChanged(b);
}
+ else
+ {
+ _bookmarkProvider.BookmarkList.Add(b.LineNum, b);
+ bookmarkAdded = true; // refresh the list only once at the end
+ }
}
// Refresh the lists
@@ -7829,8 +8048,7 @@ public void ImportBookmarkList ()
OnBookmarkAdded();
}
- dataGridView.Refresh();
- filterGridView.Refresh();
+ RefreshAllGrids();
}
catch (IOException e)
{
@@ -7889,7 +8107,9 @@ public void SetCurrentHighlightGroup (string groupName)
if (IsHandleCreated)
{
+ //NOTE: Possible double refresh of AllGrids, maybe not necessary if only will be called once
_ = BeginInvoke(new MethodInvoker(RefreshAllGrids));
+ _ = BeginInvoke(new MethodInvoker(RunHighlightBookmarkScan));
}
}
diff --git a/src/LogExpert.UI/Controls/LogWindow/PatternWindow.Designer.cs b/src/LogExpert.UI/Controls/LogWindow/PatternWindow.Designer.cs
index baf03142..f560726e 100644
--- a/src/LogExpert.UI/Controls/LogWindow/PatternWindow.Designer.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/PatternWindow.Designer.cs
@@ -51,8 +51,8 @@ private void InitializeComponent()
this.maxMissesKnobControl = new KnobControl();
this.maxDiffKnobControl = new KnobControl();
this.fuzzyKnobControl = new KnobControl();
- this.patternHitsDataGridView = new LogExpert.Dialogs.BufferedDataGridView();
- this.contentDataGridView = new LogExpert.Dialogs.BufferedDataGridView();
+ this.patternHitsDataGridView = new LogExpert.UI.Controls.BufferedDataGridView();
+ this.contentDataGridView = new LogExpert.UI.Controls.BufferedDataGridView();
this.splitContainer1.Panel1.SuspendLayout();
this.splitContainer1.Panel2.SuspendLayout();
this.splitContainer1.SuspendLayout();
@@ -402,10 +402,10 @@ private void InitializeComponent()
#endregion
- private LogExpert.Dialogs.BufferedDataGridView patternHitsDataGridView;
+ private LogExpert.UI.Controls.BufferedDataGridView patternHitsDataGridView;
private System.Windows.Forms.SplitContainer splitContainer1;
private System.Windows.Forms.SplitContainer splitContainer2;
- private LogExpert.Dialogs.BufferedDataGridView contentDataGridView;
+ private LogExpert.UI.Controls.BufferedDataGridView contentDataGridView;
private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.Label labelBlockLines;
private System.Windows.Forms.Label blockLinesLabel;
diff --git a/src/LogExpert.UI/Controls/LogWindow/PatternWindow.cs b/src/LogExpert.UI/Controls/LogWindow/PatternWindow.cs
index 6ac35af6..c815d7b0 100644
--- a/src/LogExpert.UI/Controls/LogWindow/PatternWindow.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/PatternWindow.cs
@@ -6,7 +6,6 @@
using LogExpert.Core.Classes;
using LogExpert.Core.EventArguments;
-using LogExpert.Dialogs;
namespace LogExpert.UI.Controls.LogWindow;
diff --git a/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs b/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs
index 3cac63d1..c388abbe 100644
--- a/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs
+++ b/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs
@@ -19,8 +19,7 @@ protected override void Dispose(bool disposing) {
#region Windows Form Designer generated code
///
-/// Required method for Designer support - do not modify
-/// the contents of this method with the code editor.
+/// Required method for Designer support - do not modify the contents of this method with the code editor.
///
private void InitializeComponent() {
this.components = new System.ComponentModel.Container();
@@ -30,8 +29,9 @@ private void InitializeComponent() {
this.removeCommentsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.bookmarkTextBox = new System.Windows.Forms.TextBox();
this.splitContainer1 = new System.Windows.Forms.SplitContainer();
- this.bookmarkDataGridView = new LogExpert.Dialogs.BufferedDataGridView();
+ this.bookmarkDataGridView = new LogExpert.UI.Controls.BufferedDataGridView();
this.checkBoxCommentColumn = new System.Windows.Forms.CheckBox();
+ this.convertToManualToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.labelComment = new System.Windows.Forms.Label();
this.contextMenuStrip1.SuspendLayout();
this.splitContainer1.Panel1.SuspendLayout();
@@ -44,7 +44,8 @@ private void InitializeComponent() {
//
this.contextMenuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.deleteBookmarkssToolStripMenuItem,
- this.removeCommentsToolStripMenuItem});
+ this.removeCommentsToolStripMenuItem,
+ this.convertToManualToolStripMenuItem});
this.contextMenuStrip1.Name = "contextMenuStrip1";
this.contextMenuStrip1.Size = new System.Drawing.Size(186, 48);
//
@@ -81,6 +82,13 @@ private void InitializeComponent() {
this.splitContainer1.Location = new System.Drawing.Point(0, 0);
this.splitContainer1.Name = "splitContainer1";
//
+ // convertToManualToolStripMenuItem
+ //
+ this.convertToManualToolStripMenuItem.Name = "convertToManualToolStripMenuItem";
+ this.convertToManualToolStripMenuItem.Size = new System.Drawing.Size(185, 22);
+ this.convertToManualToolStripMenuItem.Text = "Convert to manual";
+ this.convertToManualToolStripMenuItem.Click += new System.EventHandler(this.OnConvertToManualToolStripMenuItemClick);
+ //
// splitContainer1.Panel1
//
this.splitContainer1.Panel1.Controls.Add(this.bookmarkDataGridView);
@@ -184,7 +192,7 @@ private void InitializeComponent() {
#endregion
-private BufferedDataGridView bookmarkDataGridView;
+private LogExpert.UI.Controls.BufferedDataGridView bookmarkDataGridView;
private System.Windows.Forms.ContextMenuStrip contextMenuStrip1;
private System.Windows.Forms.ToolStripMenuItem deleteBookmarkssToolStripMenuItem;
private System.Windows.Forms.TextBox bookmarkTextBox;
@@ -192,4 +200,5 @@ private void InitializeComponent() {
private System.Windows.Forms.Label labelComment;
private System.Windows.Forms.ToolStripMenuItem removeCommentsToolStripMenuItem;
private System.Windows.Forms.CheckBox checkBoxCommentColumn;
+private System.Windows.Forms.ToolStripMenuItem convertToManualToolStripMenuItem;
}
\ No newline at end of file
diff --git a/src/LogExpert.UI/Dialogs/BookmarkWindow.cs b/src/LogExpert.UI/Dialogs/BookmarkWindow.cs
index 3e01bdf5..131e6f98 100644
--- a/src/LogExpert.UI/Dialogs/BookmarkWindow.cs
+++ b/src/LogExpert.UI/Dialogs/BookmarkWindow.cs
@@ -7,6 +7,7 @@
using LogExpert.Core.Entities;
using LogExpert.Core.Enums;
using LogExpert.Core.Interfaces;
+using LogExpert.UI.Controls;
using LogExpert.UI.Entities;
using LogExpert.UI.Interface;
@@ -40,7 +41,7 @@ public BookmarkWindow ()
AutoScaleMode = AutoScaleMode.Dpi;
InitializeComponent();
-
+ contextMenuStrip1.Opening += OnContextMenuStripOpening;
bookmarkDataGridView.CellValueNeeded += OnBoomarkDataGridViewCellValueNeeded;
bookmarkDataGridView.CellPainting += OnBoomarkDataGridViewCellPainting;
@@ -72,9 +73,9 @@ public bool LineColumnVisible
{
set
{
- if (bookmarkDataGridView.Columns.Count > 2)
+ if (bookmarkDataGridView.Columns.Count > 3)
{
- bookmarkDataGridView.Columns[2].Visible = value;
+ bookmarkDataGridView.Columns[3].Visible = value;
}
}
}
@@ -115,6 +116,19 @@ public void SetColumnizer (ILogLineMemoryColumnizer columnizer)
};
bookmarkDataGridView.Columns.Insert(1, commentColumn);
+
+ DataGridViewTextBoxColumn sourceColumn = new()
+ {
+ HeaderText = Resources.BookmarkWindow_UI_DataGridColumn_HeaderTextSource,
+ AutoSizeMode = DataGridViewAutoSizeColumnMode.None,
+ Resizable = DataGridViewTriState.NotSet,
+ DividerWidth = 1,
+ ReadOnly = true,
+ Width = 120,
+ MinimumWidth = 60
+ };
+
+ bookmarkDataGridView.Columns.Insert(2, sourceColumn);
ShowCommentColumn(checkBoxCommentColumn.Checked);
ResizeColumns();
}
@@ -263,20 +277,39 @@ private void SetFont (string fontName, float fontSize)
bookmarkDataGridView.Refresh();
}
- private void CommentPainting (BufferedDataGridView gridView, DataGridViewCellPaintingEventArgs e)
+ private void OnConvertToManualToolStripMenuItemClick (object sender, EventArgs e)
{
- if (e.State.HasFlag(DataGridViewElementStates.Selected))
+ foreach (DataGridViewRow row in bookmarkDataGridView.SelectedRows)
{
- using var brush = PaintHelper.GetBrushForFocusedControl(gridView.Focused, e.CellStyle.SelectionBackColor);
- e.Graphics.FillRectangle(brush, e.CellBounds);
+ if (row.Index >= 0 && row.Index < _bookmarkData.Bookmarks.Count)
+ {
+ var bookmark = _bookmarkData.Bookmarks[row.Index];
+ if (bookmark.IsAutoGenerated)
+ {
+ bookmark.IsAutoGenerated = false;
+ bookmark.SourceHighlightText = null;
+ }
+ }
}
- else
+
+ bookmarkDataGridView.Refresh();
+ _logView?.RefreshLogView();
+ }
+
+ private void OnContextMenuStripOpening (object sender, CancelEventArgs e)
+ {
+ var hasAutoGenerated = false;
+ foreach (DataGridViewRow row in bookmarkDataGridView.SelectedRows)
{
- e.CellStyle.BackColor = Color.White;
- e.PaintBackground(e.CellBounds, false);
+ if (row.Index >= 0 && row.Index < _bookmarkData.Bookmarks.Count
+ && _bookmarkData.Bookmarks[row.Index].IsAutoGenerated)
+ {
+ hasAutoGenerated = true;
+ break;
+ }
}
- e.PaintContent(e.CellBounds);
+ convertToManualToolStripMenuItem.Enabled = hasAutoGenerated;
}
private void DeleteSelectedBookmarks ()
@@ -351,14 +384,7 @@ private void OnBoomarkDataGridViewCellPainting (object sender, DataGridViewCellP
var lineNum = _bookmarkData.Bookmarks[e.RowIndex].LineNum;
- // if (e.ColumnIndex == 1)
- // {
- // CommentPainting(this.bookmarkDataGridView, lineNum, e);
- // }
- //{
- // else
PaintHelper.CellPainting(_logPaintContext, bookmarkDataGridView.Focused, lineNum, e.ColumnIndex, e);
- //}
}
catch (Exception ex)
{
@@ -384,16 +410,24 @@ private void OnBoomarkDataGridViewCellValueNeeded (object sender, DataGridViewCe
var lineNum = bookmarkForLine.LineNum;
if (e.ColumnIndex == 1)
{
- e.Value = bookmarkForLine.Text?.Replace('\n', ' ').Replace('\r', ' ');
+ e.Value = new Column { FullValue = (bookmarkForLine.Text?.Replace('\n', ' ').Replace('\r', ' ') ?? string.Empty).AsMemory() };
+ }
+
+ else if (e.ColumnIndex == 2)
+ {
+ var sourceText = bookmarkForLine.IsAutoGenerated
+ ? bookmarkForLine.SourceHighlightText ?? Resources.BookmarkWindow_UI_SourceHighlightText_Auto
+ : Resources.BookmarkWindow_UI_SourceHighlightText_Manual;
+ e.Value = new Column { FullValue = sourceText.AsMemory() };
}
else
{
- var columnIndex = e.ColumnIndex > 1 ? e.ColumnIndex - 1 : e.ColumnIndex;
+ // Columns 0, 3+ map to log columns. Offset by 2 (comment + source columns) for indices > 2.
+ var columnIndex = e.ColumnIndex > 2 ? e.ColumnIndex - 2 : e.ColumnIndex;
e.Value = _logPaintContext.GetCellValue(lineNum, columnIndex);
}
}
-
private void OnBoomarkDataGridViewMouseDoubleClick (object sender, MouseEventArgs e)
{
// if (this.bookmarkDataGridView.CurrentRow != null)
diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs
index 2993618f..a4de7368 100644
--- a/src/LogExpert.UI/Entities/PaintHelper.cs
+++ b/src/LogExpert.UI/Entities/PaintHelper.cs
@@ -4,7 +4,6 @@
using LogExpert.Core.Classes.Highlight;
using LogExpert.Core.Entities;
-using LogExpert.Dialogs;
using LogExpert.UI.Controls;
using LogExpert.UI.Interface;
@@ -17,6 +16,12 @@ internal static class PaintHelper
{
#region Fields
+ private static readonly StringFormat _format = new()
+ {
+ LineAlignment = StringAlignment.Center,
+ Alignment = StringAlignment.Center
+ };
+
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
#endregion
@@ -72,15 +77,9 @@ public static void CellPainting (ILogPaintContextUI logPaintCtx, bool focused, i
if (bookmark.Text.Length > 0)
{
- StringFormat format = new()
- {
- LineAlignment = StringAlignment.Center,
- Alignment = StringAlignment.Center
- };
-
using var brush2 = new SolidBrush(Color.FromArgb(255, 190, 100, 0)); //DarkOrange
using var font = logPaintCtx.MonospacedFont;
- e.Graphics.DrawString("i", font, brush2, new RectangleF(r.Left, r.Top, r.Width, r.Height), format);
+ e.Graphics.DrawString("i", font, brush2, new RectangleF(r.Left, r.Top, r.Width, r.Height), _format);
}
}
}
diff --git a/src/LogExpert/Classes/LogExpertProxy.cs b/src/LogExpert/Classes/LogExpertProxy.cs
index 4f3c4503..20bad4f6 100644
--- a/src/LogExpert/Classes/LogExpertProxy.cs
+++ b/src/LogExpert/Classes/LogExpertProxy.cs
@@ -19,7 +19,7 @@ internal class LogExpertProxy : ILogExpertProxy
[NonSerialized] private ILogTabWindow _firstLogTabWindow;
- [NonSerialized] private ILogTabWindow _mostRecentActiveWindow; // ⭐ PHASE 2: Track most recently activated window
+ [NonSerialized] private ILogTabWindow _mostRecentActiveWindow; // Track most recently activated window
[NonSerialized] private int _logWindowIndex = 1;
diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs
index 034207e3..3e1c6d40 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-04-08 11:38:39 UTC
+ /// Generated: 2026-04-11 07:50:46 UTC
/// Configuration: Release
/// Plugin count: 22
///
@@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes()
{
return new Dictionary(StringComparer.OrdinalIgnoreCase)
{
- ["AutoColumnizer.dll"] = "71ADBC14647A3518D5BCC9B7C457E245ED9BC09361DF86E35F720810C287EB4E",
+ ["AutoColumnizer.dll"] = "D36D2E597CB0013725F96DBCB7BBF5D7507DE06EFBFDB7052CA8A578FC73A2D0",
["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6",
- ["CsvColumnizer.dll"] = "C8222B75CA9624DBF4AB4E61E267B1A77D89F8DF060936E089B028CDEE640BCB",
- ["CsvColumnizer.dll (x86)"] = "C8222B75CA9624DBF4AB4E61E267B1A77D89F8DF060936E089B028CDEE640BCB",
- ["DefaultPlugins.dll"] = "B6522E225406331F33F85AA632E137EDC09A979ED616C075783EB08E346E236E",
- ["FlashIconHighlighter.dll"] = "895F1716EBAC443B0960D39C97041ABD19B93F81387CF3CDDA831E3DB3FD6504",
- ["GlassfishColumnizer.dll"] = "E7B51257921307710D6ECDE215F55E7FBB62EE509CA8C5573D111D7576ECB5BE",
- ["JsonColumnizer.dll"] = "E64E8482258A9569EB5C2143CAD23711A192434A78F36B83743885EDF0BA44F1",
- ["JsonCompactColumnizer.dll"] = "4C5019A770C94A84269C373C577DD5C399CF7A9A9A2F3D840E5C267D67B96E19",
- ["Log4jXmlColumnizer.dll"] = "DF8E5AF3C23E4475902BD14C459E744A0EB14BFFF65CF423B8F3B81C6D636F92",
- ["LogExpert.Core.dll"] = "F6E015EDA26C27BB8C5571527525C3A63DB7C3B90B73DC2B28803455AFAF7899",
- ["LogExpert.Resources.dll"] = "C6CC3738AB9C5FC016B524E08B80CB31A8783550A0DDB864BDE97A93EAAEE9EE",
+ ["CsvColumnizer.dll"] = "E6453F86132B5FC4684FAC1D7D295C8A07B57E0F130DCBAE2F5D0B519AE629A6",
+ ["CsvColumnizer.dll (x86)"] = "E6453F86132B5FC4684FAC1D7D295C8A07B57E0F130DCBAE2F5D0B519AE629A6",
+ ["DefaultPlugins.dll"] = "8229DED288584A0A194707FCD681729ED1C1A2AF4FDD8FA9979DD30FFF179494",
+ ["FlashIconHighlighter.dll"] = "4318D8B3F749EAE3B06B1927EE55F5B89DDCB365998389AD72D1B06774900534",
+ ["GlassfishColumnizer.dll"] = "9044D36D3746CC3435255560D85441AEA234B3AB1BAC0888CBA0DE5CFF3ADC52",
+ ["JsonColumnizer.dll"] = "06AD09BC01B20F66D9C60E1C047AA0E78375EB952779C910C2206BD1F3E4C893",
+ ["JsonCompactColumnizer.dll"] = "B2A6CD40D3717DC181E5C9D8FC1ED26117B181475D801FC942DF7769F85EBA2C",
+ ["Log4jXmlColumnizer.dll"] = "36F5648EBC0A007DF82F68933DF392CFD9942C1F31F166EF4CB8C60507997487",
+ ["LogExpert.Core.dll"] = "ED98A22A79F05DD2C0B595FB13C90729D1B3660034C813BD493A037602679232",
+ ["LogExpert.Resources.dll"] = "9A3F67A6405D2560FFAB54483229C686E5F9A9DE60F686910CEA23E19AC4FDAF",
["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"] = "5FCDACB87036DACC72DFAF7F61D44087F21C835E4AE1EAFED3E072D3F45E6F9E",
- ["SftpFileSystem.dll"] = "90923B8231C88526EA10BAB744360B1B0DCBD9C7E09DF332C4C52EC79D594468",
- ["SftpFileSystem.dll (x86)"] = "BFACC69BF8CF3CA6255C2D1F7DEEEA7D79FE6804121A9E6FFE2B345B8772BF91",
- ["SftpFileSystem.Resources.dll"] = "148A73361F30CCC5B0CBF46D3B38D83FE0F21F3D9B43D567AE6D29636D49A801",
- ["SftpFileSystem.Resources.dll (x86)"] = "148A73361F30CCC5B0CBF46D3B38D83FE0F21F3D9B43D567AE6D29636D49A801",
+ ["RegexColumnizer.dll"] = "F13240DC73541B68E24A1EED63E19052AD9D7FAD612DF644ACC053D023E3156A",
+ ["SftpFileSystem.dll"] = "1F6D11FA4C748E014A3A15A20EFFF4358AD14C391821F45F4AECA6D9A3D47C60",
+ ["SftpFileSystem.dll (x86)"] = "6A8CC68ED4BBED8838FCA45E74AA31F73F0BFEAFD400C278FBC48ED2FF8FF180",
+ ["SftpFileSystem.Resources.dll"] = "64686BBECECB92AA5A7B13EF98C1CDCC1D2ECA4D9BBE1A1B367A2CA39BB5B6BD",
+ ["SftpFileSystem.Resources.dll (x86)"] = "64686BBECECB92AA5A7B13EF98C1CDCC1D2ECA4D9BBE1A1B367A2CA39BB5B6BD",
};
}