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