From 515021c7a4f0d8a4f296baf38c824c02ffdabfeb Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 8 Apr 2026 17:18:25 +0200 Subject: [PATCH 01/16] update BufferedDataGridView --- .../Controls/BufferedDataGridView.Designer.cs | 22 +- .../Controls/BufferedDataGridView.cs | 214 +++++++++++++++--- .../LogWindow/PatternWindow.Designer.cs | 8 +- .../Controls/LogWindow/PatternWindow.cs | 1 - .../Dialogs/BookmarkWindow.Designer.cs | 4 +- src/LogExpert.UI/Dialogs/BookmarkWindow.cs | 1 + src/LogExpert.UI/Entities/PaintHelper.cs | 1 - 7 files changed, 196 insertions(+), 55 deletions(-) diff --git a/src/LogExpert.UI/Controls/BufferedDataGridView.Designer.cs b/src/LogExpert.UI/Controls/BufferedDataGridView.Designer.cs index f4908a0f0..d37177145 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 8aa2df6fc..5ed5798d3 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,37 @@ 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 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 Font _font = new("Segoe UI", 9.75f); + private Pen? _pen; + private Brush? _brush; + private Brush? _textBrush; + private Color _currentBubbleColor; + private Color _currentTextColor; + + private readonly StringFormat _format = new() + { + LineAlignment = StringAlignment.Center, + Alignment = StringAlignment.Near + }; private readonly SortedList _overlayList = []; - private readonly Pen _pen; - private readonly Brush _textBrush = new SolidBrush(Color.FromArgb(200, 0, 0, 90)); //dark blue + private readonly Lock _overlayLock = new(); + private readonly List _overlayStaging = []; + private BookmarkOverlay[] _overlaySnapshot = []; - private BookmarkOverlay _draggedOverlay; + private BookmarkOverlay? _draggedOverlay; private Point _dragStartPoint; private bool _isDrag; private Size _oldOverlayOffset; @@ -37,9 +56,6 @@ internal partial class BufferedDataGridView : DataGridView public BufferedDataGridView () { - _pen = new Pen(_bubbleColor, (float)3.0); - _brush = new SolidBrush(_bubbleColor); - InitializeComponent(); DoubleBuffered = true; VirtualMode = true; @@ -74,23 +90,81 @@ public Graphics Buffer public void AddOverlay (BookmarkOverlay overlay) { - lock (_overlayList) + lock (_overlayLock) { - _overlayList.Add(overlay.Position.Y, overlay); + _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) + { + _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 { if (PaintWithOverlays) { - PaintOverlays(e); + NewPaintOverlays(e); } else { @@ -99,10 +173,89 @@ 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 (Exception innerEx) + { + _logger.Error($"Base paint also failed. {innerEx}"); + } } } + private void NewPaintOverlays (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) { base.OnEditingControlShowing(e); @@ -159,7 +312,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,20 +343,29 @@ protected override void OnMouseDoubleClick (MouseEventArgs e) } } + protected override void OnMouseLeave (EventArgs e) + { + if (!_isDrag) + { + Cursor = Cursors.Default; + } + + base.OnMouseLeave(e); + } + #endregion #region Private Methods private BookmarkOverlay GetOverlayForPosition (Point pos) { - lock (_overlayList) + var overlays = _overlaySnapshot; + + foreach (var overlay in overlays) { - foreach (var overlay in _overlayList.Values) + if (overlay.BubbleRect.Contains(pos)) { - if (overlay.BubbleRect.Contains(pos)) - { - return overlay; - } + return overlay; } } @@ -227,12 +389,6 @@ private void PaintOverlays (PaintEventArgs e) base.OnPaint(args); - StringFormat format = new() - { - LineAlignment = StringAlignment.Center, - Alignment = StringAlignment.Near - }; - myBuffer.Graphics.SetClip(DisplayRectangle, CombineMode.Intersect); // Remove Columnheader from Clippingarea @@ -260,7 +416,7 @@ private void PaintOverlays (PaintEventArgs e) 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); + myBuffer.Graphics.DrawString(overlay.Bookmark.Text, _font, _textBrush, textRect, _format); if (_logger.IsDebugEnabled) { diff --git a/src/LogExpert.UI/Controls/LogWindow/PatternWindow.Designer.cs b/src/LogExpert.UI/Controls/LogWindow/PatternWindow.Designer.cs index baf031427..f560726e5 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 6ac35af67..c815d7b03 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 3cac63d17..b0e107f8f 100644 --- a/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs +++ b/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs @@ -30,7 +30,7 @@ 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.labelComment = new System.Windows.Forms.Label(); this.contextMenuStrip1.SuspendLayout(); @@ -184,7 +184,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; diff --git a/src/LogExpert.UI/Dialogs/BookmarkWindow.cs b/src/LogExpert.UI/Dialogs/BookmarkWindow.cs index 3e01bdf5b..f9908a5dd 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; diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs index 2993618f5..eb2c8c1a9 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; From cbce537b9e0bb1e22ccbfd24f89016cc846e3c2e Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 8 Apr 2026 17:20:16 +0200 Subject: [PATCH 02/16] removed no longer used functions --- .../Controls/BufferedDataGridView.cs | 69 +------------------ 1 file changed, 2 insertions(+), 67 deletions(-) diff --git a/src/LogExpert.UI/Controls/BufferedDataGridView.cs b/src/LogExpert.UI/Controls/BufferedDataGridView.cs index 5ed5798d3..920bd0a04 100644 --- a/src/LogExpert.UI/Controls/BufferedDataGridView.cs +++ b/src/LogExpert.UI/Controls/BufferedDataGridView.cs @@ -39,8 +39,6 @@ internal partial class BufferedDataGridView : DataGridView Alignment = StringAlignment.Near }; - private readonly SortedList _overlayList = []; - private readonly Lock _overlayLock = new(); private readonly List _overlayStaging = []; private BookmarkOverlay[] _overlaySnapshot = []; @@ -71,13 +69,6 @@ public BufferedDataGridView () #region Properties - /* - public Graphics Buffer - { - get { return this.myBuffer.Graphics; } - } - */ - [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public ContextMenuStrip EditModeMenuStrip { get; set; } @@ -164,7 +155,7 @@ protected override void OnPaint (PaintEventArgs e) { if (PaintWithOverlays) { - NewPaintOverlays(e); + PaintOverlays(e); } else { @@ -186,7 +177,7 @@ protected override void OnPaint (PaintEventArgs e) } } - private void NewPaintOverlays (PaintEventArgs e) + private void PaintOverlays (PaintEventArgs e) { EnsureDrawingResources(); @@ -372,62 +363,6 @@ private BookmarkOverlay GetOverlayForPosition (Point pos) return null; } - 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); - - myBuffer.Graphics.SetClip(DisplayRectangle, CombineMode.Intersect); - - // Remove Columnheader from Clippingarea - Rectangle rectTableHeader = new(DisplayRectangle.X, DisplayRectangle.Y, DisplayRectangle.Width, ColumnHeadersHeight); - myBuffer.Graphics.SetClip(rectTableHeader, CombineMode.Exclude); - - //e.Graphics.SetClip(rect, CombineMode.Union); - - lock (_overlayList) - { - foreach (var overlay in _overlayList.Values) - { - 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}"); - } - } - } - - myBuffer.Render(e.Graphics); - } - #endregion #region Events handler From c07c688d28574135c9ae59893fde5650bcb5f82b Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 10 Apr 2026 10:13:11 +0200 Subject: [PATCH 03/16] updated comment --- src/LogExpert/Classes/LogExpertProxy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LogExpert/Classes/LogExpertProxy.cs b/src/LogExpert/Classes/LogExpertProxy.cs index 4f3c4503a..20bad4f6e 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; From 32d426dd32a65da71ec4437346ba401bdcdeaedd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 08:18:03 +0000 Subject: [PATCH 04/16] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 9d06d000e..125e6e8c9 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 07:36:39 UTC + /// Generated: 2026-04-10 08:18:02 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "16B4911C5434A32CE56FC6E2FD736BCE09A60763AAA89516D5830DCA57F2FC98", + ["AutoColumnizer.dll"] = "FAE102DE1B36478E96D92115A5C808CA2781958ABBE1CC7E2ECD3EF3C205C626", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "09A5788A8CF879D167386B7B0281EAE7872CC3EB5F8580D203D7C8170FB2E557", - ["CsvColumnizer.dll (x86)"] = "09A5788A8CF879D167386B7B0281EAE7872CC3EB5F8580D203D7C8170FB2E557", - ["DefaultPlugins.dll"] = "11C939FF576411744B4529363F39BFD4E9EBB89E5578D142D3B661E4A3027126", - ["FlashIconHighlighter.dll"] = "C395463B2A0B9585D0195EEAD3AD9573AA1DADC784B00813239CA30BEA72E0AA", - ["GlassfishColumnizer.dll"] = "796DE5895F8CA06579F9026F16F66229C8B67F33681DABCC51D07BB2C13865B0", - ["JsonColumnizer.dll"] = "903342AC2A712C59932ECF3C74E27B88C7DC95337330FD733424042E10FA9C9C", - ["JsonCompactColumnizer.dll"] = "C5E19A193D76104A005483065E1649C15CA2D3C495A9BB465D95341006ECFE2A", - ["Log4jXmlColumnizer.dll"] = "544A78E1C538F31310A48FD0F894D4A7A602C46BA04FFCB74DD1035A45AC91FF", - ["LogExpert.Core.dll"] = "77BF77CEBCCE5C3DBFCCD9CE3D6D175436532D78AE2EB6B92B10602792FAE117", - ["LogExpert.Resources.dll"] = "869425BD30DA40E85F367C3D082F62C78F20B7DBC81F0960BE45884BFBFBA575", + ["CsvColumnizer.dll"] = "2F6313103EF3BB10B9973BB82AF4DA35E7E7A54C40B1D9941B91F2D199A2B921", + ["CsvColumnizer.dll (x86)"] = "2F6313103EF3BB10B9973BB82AF4DA35E7E7A54C40B1D9941B91F2D199A2B921", + ["DefaultPlugins.dll"] = "C6690034B82EB82007B50394B48A57FB4FD2F12847559FA5C5CBD234F518CC98", + ["FlashIconHighlighter.dll"] = "630EF79943AD281EAC6E6EA0E4FEB6226DB2E1D4793197396046D3BF136FA1D9", + ["GlassfishColumnizer.dll"] = "23C3332461C2496AE892E8B0EBCB8A815CFE80AE5C524481BD972678F7799C37", + ["JsonColumnizer.dll"] = "D78BBED9343C0613D46534AB124319CA14AE0FC418487FB44829A15DA3CA288A", + ["JsonCompactColumnizer.dll"] = "4F8FE98B0373B83B14FF4FB9BBA569CD96BD7ADB9CFB27C6B29E1DDF6D68A667", + ["Log4jXmlColumnizer.dll"] = "AD6D5CECDCB17C4E8B11F826999160400766943ED7BC2C9B0DD1D02122E6F1DD", + ["LogExpert.Core.dll"] = "76B71668B1369C4AF0C4494149AB9AA909AAEAA45B7DD71F7BDE28658940AA21", + ["LogExpert.Resources.dll"] = "8B1C3BD2CE6385A2A9E681D231D65C01FC2713B7201605460E170E5B700CDADC", ["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"] = "E93D37F9344CECA55EE40133B38801BFBC5D4B0AE0DDF10C04EEAD70CAE08CC0", - ["SftpFileSystem.dll"] = "04CAC2FCD43803C8EBF090A41D458674BDE5AB98CAC2452D29A2CDF58F6C29CC", - ["SftpFileSystem.dll (x86)"] = "2822F059492BCE7902CAF521A034624B1DEA615AE935A1D082B591EE8179245B", - ["SftpFileSystem.Resources.dll"] = "166515F144CD78ACC8E5C3827AAAF24959F9AD7786917791FB71398B03014C32", - ["SftpFileSystem.Resources.dll (x86)"] = "166515F144CD78ACC8E5C3827AAAF24959F9AD7786917791FB71398B03014C32", + ["RegexColumnizer.dll"] = "B106DB3DC8177A1737BDD27678D9860D2B5A74679443AD51C10A97A9431D1764", + ["SftpFileSystem.dll"] = "09F3EFCBE2130DA22DAE9415E72562BFC36FCFF3C45DF9065EEEEF7DE573F4A6", + ["SftpFileSystem.dll (x86)"] = "D590918A5C4874C231A46727B9AADB34EC2E552AFCF4AFE64FB5F6801F0C35A6", + ["SftpFileSystem.Resources.dll"] = "7D5EFE18459F74A67FC908527A5F4108643781FB73F97BF140D76BF4313895A9", + ["SftpFileSystem.Resources.dll (x86)"] = "7D5EFE18459F74A67FC908527A5F4108643781FB73F97BF140D76BF4313895A9", }; } From 0b161b8675aadeb6773825e05ff6d9c7946c8228 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 10 Apr 2026 17:10:56 +0200 Subject: [PATCH 05/16] bookmarks per trigger, with static files --- .../Classes/Bookmark/BookmarkDataProvider.cs | 64 +++- .../Bookmark/HighlightBookmarkScanner.cs | 159 ++++++++++ .../Classes/Columnizer/TimestampColumnizer.cs | 21 +- .../Classes/Filter/FilterCancelHandler.cs | 12 +- .../Classes/Filter/FilterStarter.cs | 2 +- src/LogExpert.Core/Entities/Bookmark.cs | 24 ++ .../Bookmark/HighlightBookmarkScannerTests.cs | 272 +++++++++++++++++ .../HighlightBookmarkTriggerTests.cs | 288 +++++++++++++++++- .../Controls/LogWindow/LogWindow.cs | 192 ++++++++++-- 9 files changed, 968 insertions(+), 66 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs create mode 100644 src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs rename src/LogExpert.Tests/{ => Bookmark}/HighlightBookmarkTriggerTests.cs (50%) diff --git a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs index 0a1e7963b..9c1f99771 100644 --- a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs +++ b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs @@ -97,6 +97,38 @@ 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 = []; + foreach (var kvp in BookmarkList) + { + if (kvp.Value.IsAutoGenerated) + { + keysToRemove.Add(kvp.Key); + } + } + + foreach (var key in keysToRemove) + { + _ = BookmarkList.Remove(key); + removed = true; + } + } + + if (removed) + { + OnBookmarkRemoved(); + } + } + public void ShiftBookmarks (int offset) { SortedList newBookmarkList = []; @@ -169,7 +201,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 +212,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 000000000..44b963cac --- /dev/null +++ b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs @@ -0,0 +1,159 @@ +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 bookmarkComment = string.Empty; + var sourceHighlightText = string.Empty; + + foreach (var entry in bookmarkEntries) + { + if (CheckHighlightEntryMatch(entry, line)) + { + setBookmark = true; + sourceHighlightText = entry.SearchText; + + if (!string.IsNullOrEmpty(entry.BookmarkComment)) + { + bookmarkComment += entry.BookmarkComment + "\r\n"; + } + } + } + + bookmarkComment = bookmarkComment.TrimEnd('\r', '\n'); + + return (setBookmark, bookmarkComment, 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 f8a3bb552..f1d2a2d77 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 f146cf3d8..5da892ee5 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 be14bfbc3..be2f03cc2 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/Entities/Bookmark.cs b/src/LogExpert.Core/Entities/Bookmark.cs index 4ae563a35..2da5e7af4 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.Tests/Bookmark/HighlightBookmarkScannerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs new file mode 100644 index 000000000..67a2b6875 --- /dev/null +++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs @@ -0,0 +1,272 @@ +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 } + }; + 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/HighlightBookmarkTriggerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs similarity index 50% rename from src/LogExpert.Tests/HighlightBookmarkTriggerTests.cs rename to src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs index a50730a91..805130911 100644 --- a/src/LogExpert.Tests/HighlightBookmarkTriggerTests.cs +++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs @@ -1,10 +1,9 @@ using LogExpert.Core.Classes.Bookmark; using LogExpert.Core.Classes.Highlight; -using LogExpert.Core.Entities; using NUnit.Framework; -namespace LogExpert.Tests; +namespace LogExpert.Tests.Bookmark; [TestFixture] public class HighlightBookmarkTriggerTests @@ -135,9 +134,8 @@ public void GetHighlightActions_WhenBookmarkCommentIsNull_ReturnsSetBookmarkTrue } /// - /// 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. + /// 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) { @@ -182,7 +180,7 @@ public void BookmarkDataProvider_AddBookmark_IsBookmarkAtLineReturnsTrue () { // Arrange var provider = new BookmarkDataProvider(); - var bookmark = new Bookmark(42, "Test comment"); + var bookmark = new Core.Entities.Bookmark(42, "Test comment"); // Act provider.AddBookmark(bookmark); @@ -196,7 +194,7 @@ public void BookmarkDataProvider_AddBookmark_GetBookmarkReturnsCorrectBookmark ( { // Arrange var provider = new BookmarkDataProvider(); - var bookmark = new Bookmark(42, "Test comment"); + var bookmark = new Core.Entities.Bookmark(42, "Test comment"); // Act provider.AddBookmark(bookmark); @@ -213,7 +211,7 @@ public void BookmarkDataProvider_RemoveBookmark_IsBookmarkAtLineReturnsFalse () { // Arrange var provider = new BookmarkDataProvider(); - provider.AddBookmark(new Bookmark(42, "Test")); + provider.AddBookmark(new Core.Entities.Bookmark(42, "Test")); // Act provider.RemoveBookmarkForLine(42); @@ -231,7 +229,7 @@ public void BookmarkDataProvider_AddBookmark_FiresBookmarkAddedEvent () provider.BookmarkAdded += (_, _) => eventFired = true; // Act - provider.AddBookmark(new Bookmark(42)); + provider.AddBookmark(new Core.Entities.Bookmark(42)); // Assert Assert.That(eventFired, Is.True); @@ -283,9 +281,9 @@ public void HighlightEntry_Clone_PreservesIsSetBookmarkWhenFalse () #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. + /// 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 () @@ -317,8 +315,8 @@ public void ClosureBug_ForLoopVariable_CapturedByReference_DemonstratesBug () } /// - /// Demonstrates the correct pattern — capturing the loop variable in a local. - /// This is the pattern that must be applied in CheckFilterAndHighlight(). + /// 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 () @@ -347,5 +345,267 @@ public void ClosureFix_LocalCapture_AllValuesCorrect () 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 } \ No newline at end of file diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 5039c726f..ece2b1b0e 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(); @@ -675,8 +686,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); @@ -2914,12 +2923,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 +2984,7 @@ private void LogEventWorker () private void StopLogEventWorkerThread () { _ = _logEventArgsEvent.Set(); - cts.Cancel(); + _cts.Cancel(); //_logEventHandlerThread.Abort(); //_logEventHandlerThread.Join(); } @@ -3521,7 +3530,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). @@ -3746,7 +3755,7 @@ private void StopTimestampSyncThread () //_timeShiftSyncWakeupEvent.Set(); //_timeShiftSyncThread.Abort(); //_timeShiftSyncThread.Join(); - cts.Cancel(); + _cts.Cancel(); } [SupportedOSPlatform("windows")] @@ -6340,6 +6349,7 @@ public void Close (bool dontAsk) public void CloseLogWindow () { + CancelHighlightBookmarkScan(); StopTimespreadThread(); StopTimestampSyncThread(); StopLogEventWorkerThread(); @@ -6528,19 +6538,13 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi 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 +6799,11 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) _shouldCancel = true; } + CancelHighlightBookmarkScan(); FireCancelHandlers(); RemoveAllSearchHighlightEntries(); + + break; } case Keys.E when (e.Modifiers & Keys.Control) == Keys.Control: @@ -7000,8 +7007,7 @@ public void ToggleBookmark (int lineNum) _bookmarkProvider.AddBookmark(new Bookmark(lineNum)); } - dataGridView.Refresh(); - filterGridView.Refresh(); + RefreshAllGrids(); OnBookmarkAdded(); } @@ -7037,6 +7043,136 @@ public void SetBookmarkFromTrigger (int lineNum, string comment) OnBookmarkAdded(); } + /// + /// 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("Scanning bookmarks..."); + _progressEventArgs.MinValue = 0; + _progressEventArgs.MaxValue = lineCount; + _progressEventArgs.Value = 0; + _progressEventArgs.Visible = true; + SendProgressBarUpdate(); + + var progress = new Progress(currentLine => + { + // Marshal progress update to UI thread + if (IsHandleCreated && !IsDisposed) + { + _ = BeginInvoke(() => + { + _progressEventArgs.Value = currentLine; + SendProgressBarUpdate(); + + if (lineCount > 0) + { + var pct = (int)((long)currentLine * 100 / lineCount); + StatusLineText($"Scanning bookmarks... {pct}%"); + } + }); + } + }); + + _ = Task.Run(() => + { + try + { + var bookmarks = HighlightBookmarkScanner.Scan(lineCount, i => _logFileReader.GetLogLineMemory(i), 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; + } + + cts.Dispose(); + } + }); + } + + /// + /// 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 +7945,17 @@ public void ImportBookmarkList () var bookmarkAdded = false; foreach (var b in newBookmarks.Values) { - if (!_bookmarkProvider.BookmarkList.ContainsKey(b.LineNum)) + if (_bookmarkProvider.BookmarkList.TryGetValue(b.LineNum, out Bookmark? existingBookmark)) { - _bookmarkProvider.BookmarkList.Add(b.LineNum, b); - bookmarkAdded = true; // refresh the list only once at the end - } - else - { - 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 +7964,7 @@ public void ImportBookmarkList () OnBookmarkAdded(); } - dataGridView.Refresh(); - filterGridView.Refresh(); + RefreshAllGrids(); } catch (IOException e) { @@ -7889,7 +8023,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)); } } From a05f28f3b4b847d34e63da736ba0927b11f095fb Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 10 Apr 2026 18:09:14 +0200 Subject: [PATCH 06/16] binary search? what? who needs that ;D --- src/LogExpert.Core/Classes/Log/LogBuffer.cs | 2 + .../Classes/Log/LogfileReader.cs | 797 ++++++++++++------ 2 files changed, 544 insertions(+), 255 deletions(-) diff --git a/src/LogExpert.Core/Classes/Log/LogBuffer.cs b/src/LogExpert.Core/Classes/Log/LogBuffer.cs index b189e7b93..ae34615d9 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 57f9eb1a7..ae9b66d8f 100644 --- a/src/LogExpert.Core/Classes/Log/LogfileReader.cs +++ b/src/LogExpert.Core/Classes/Log/LogfileReader.cs @@ -54,6 +54,9 @@ public partial class LogfileReader : IAutoLogLineMemoryColumnizerCallback, IDisp private bool _disposed; private ILogFileInfo _watchedILogFileInfo; + private volatile LogBuffer _lastBuffer; + 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,15 @@ 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 () + { + _lastBuffer = null; + _lastBufferIndex = -1; + } + /// /// Explicit change the encoding. /// @@ -826,8 +861,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 +890,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 +955,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 +1000,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 +1016,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 +1032,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 +1064,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 +1082,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 +1108,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 +1167,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 +1187,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 +1402,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 +1466,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 +1493,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 +1558,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 +1583,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 +1608,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,35 +1701,154 @@ 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 list = _bufferList; + var count = list.Count; + + 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 = list[lastIdx]; + if (lineNum >= buf.StartLine && lineNum < buf.StartLine + buf.LineCount) + { + UpdateLruCache(buf); + return buf; + } + + // Layer 1: Adjacent buffer prediction — O(1) for buffer boundary crossings + if (lastIdx + 1 < count) + { + var next = list[lastIdx + 1]; + if (lineNum >= next.StartLine && lineNum < next.StartLine + next.LineCount) + { + _lastBuffer = next; + _lastBufferIndex = lastIdx + 1; + UpdateLruCache(next); + return next; + } + } + + if (lastIdx - 1 >= 0) + { + var prev = list[lastIdx - 1]; + if (lineNum >= prev.StartLine && lineNum < prev.StartLine + prev.LineCount) + { + _lastBuffer = prev; + _lastBufferIndex = lastIdx - 1; + UpdateLruCache(prev); + return prev; + } + } + } + + // Layer 2: Direct mapping guess — O(1) speculative for uniform buffers + var guess = lineNum / _maxLinesPerBuffer; + if (guess >= 0 && guess < count) + { + var buf = list[guess]; + if (lineNum >= buf.StartLine && lineNum < buf.StartLine + buf.LineCount) + { + _lastBuffer = buf; + _lastBufferIndex = guess; + UpdateLruCache(buf); + return buf; + } + } + + // Layer 3: Binary search fallback — O(log n) guaranteed + int lo = 0, hi = count - 1; + while (lo <= hi) + { + int mid = (lo + hi) >> 1; + var buf = list[mid]; + + if (lineNum < buf.StartLine) + { + hi = mid - 1; + } + else if (lineNum >= buf.StartLine + buf.LineCount) + { + lo = mid + 1; + } + else + { + _lastBuffer = buf; + _lastBufferIndex = mid; + 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 void GetLineMemoryFinishedCallback (ILogLineMemory line) @@ -1672,11 +1867,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 +1906,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 +1984,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 +2011,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 +2037,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 +2112,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 +2138,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 +2162,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 +2215,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 +2272,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 +2290,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 +2308,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 +2326,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 +2343,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 +2359,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 +2371,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 +2384,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 +2396,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 +2408,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 +2422,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 +2440,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 +2458,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 +2471,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 +2488,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 +2506,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 +2523,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 +2535,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 +2547,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 +2561,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 +2592,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 +2605,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 +2631,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 +2648,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 +2661,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 +2674,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 +2687,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 +2699,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 +2711,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()"); From 787a74445b0463af98d206ed6e7c9a8eb0e9044a Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 10 Apr 2026 18:28:44 +0200 Subject: [PATCH 07/16] powers of 2 strides optimizations precomputed EndofLine optimizations unsinged range check optimizations --- .../Classes/Log/LogfileReader.cs | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/LogExpert.Core/Classes/Log/LogfileReader.cs b/src/LogExpert.Core/Classes/Log/LogfileReader.cs index ae9b66d8f..ac21e99f0 100644 --- a/src/LogExpert.Core/Classes/Log/LogfileReader.cs +++ b/src/LogExpert.Core/Classes/Log/LogfileReader.cs @@ -54,7 +54,6 @@ public partial class LogfileReader : IAutoLogLineMemoryColumnizerCallback, IDisp private bool _disposed; private ILogFileInfo _watchedILogFileInfo; - private volatile LogBuffer _lastBuffer; private volatile int _lastBufferIndex = -1; #endregion @@ -1769,7 +1768,8 @@ private LogBuffer GetBufferForLine (int lineNum) if (lastIdx >= 0 && lastIdx < count) { var buf = list[lastIdx]; - if (lineNum >= buf.StartLine && lineNum < buf.StartLine + buf.LineCount) + if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) + //if (lineNum >= buf.StartLine && lineNum < buf.EndLine) { UpdateLruCache(buf); return buf; @@ -1779,7 +1779,8 @@ private LogBuffer GetBufferForLine (int lineNum) if (lastIdx + 1 < count) { var next = list[lastIdx + 1]; - if (lineNum >= next.StartLine && lineNum < next.StartLine + next.LineCount) + if ((uint)(lineNum - next.StartLine) < (uint)next.LineCount) + //if (lineNum >= next.StartLine && lineNum < next.EndLine) { _lastBuffer = next; _lastBufferIndex = lastIdx + 1; @@ -1791,7 +1792,8 @@ private LogBuffer GetBufferForLine (int lineNum) if (lastIdx - 1 >= 0) { var prev = list[lastIdx - 1]; - if (lineNum >= prev.StartLine && lineNum < prev.StartLine + prev.LineCount) + if ((uint)(lineNum - prev.StartLine) < (uint)prev.LineCount) + //if (lineNum >= prev.StartLine && lineNum < prev.EndLine) { _lastBuffer = prev; _lastBufferIndex = lastIdx - 1; @@ -1806,7 +1808,8 @@ private LogBuffer GetBufferForLine (int lineNum) if (guess >= 0 && guess < count) { var buf = list[guess]; - if (lineNum >= buf.StartLine && lineNum < buf.StartLine + buf.LineCount) + if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) + //if (lineNum >= buf.StartLine && lineNum < buf.EndLine) { _lastBuffer = buf; _lastBufferIndex = guess; @@ -1815,25 +1818,27 @@ private LogBuffer GetBufferForLine (int lineNum) } } - // Layer 3: Binary search fallback — O(log n) guaranteed - int lo = 0, hi = count - 1; - while (lo <= hi) - { - int mid = (lo + hi) >> 1; - var buf = list[mid]; + // Layer 3: Branchless binary search with power-of-two strides + var step = HighestPowerOfTwo(count); + var idx = (list[step - 1].StartLine <= lineNum) ? count - step : 0; - if (lineNum < buf.StartLine) - { - hi = mid - 1; - } - else if (lineNum >= buf.StartLine + buf.LineCount) + for (step >>= 1; step > 0; step >>= 1) + { + var probe = idx + step; + if (probe < count && list[probe - 1].StartLine <= lineNum) { - lo = mid + 1; + idx = probe; } - else + } + + // idx is now the buffer index — verify bounds + if (idx < count) + { + var buf = list[idx]; + if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) { _lastBuffer = buf; - _lastBufferIndex = mid; + _lastBufferIndex = idx; UpdateLruCache(buf); return buf; } @@ -1851,6 +1856,8 @@ private LogBuffer GetBufferForLine (int lineNum) } } + private static int HighestPowerOfTwo (int n) => 1 << (31 - int.LeadingZeroCount(n)); + private void GetLineMemoryFinishedCallback (ILogLineMemory line) { _isFailModeCheckCallPending = false; From 926609e105ac827b4f97fdbc79647ea3619546dd Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 10 Apr 2026 18:50:33 +0200 Subject: [PATCH 08/16] span and hotpath optimisations --- .../Classes/Log/LogfileReader.cs | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/LogExpert.Core/Classes/Log/LogfileReader.cs b/src/LogExpert.Core/Classes/Log/LogfileReader.cs index ac21e99f0..ad2b43ced 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; @@ -817,7 +818,6 @@ public void DeleteAllContent () /// private void ClearBufferState () { - _lastBuffer = null; _lastBufferIndex = -1; } @@ -1755,8 +1755,8 @@ private LogBuffer GetBufferForLine (int lineNum) AcquireBufferListReaderLock(); try { - var list = _bufferList; - var count = list.Count; + var arr = CollectionsMarshal.AsSpan(_bufferList); + var count = arr.Length; if (count == 0) { @@ -1767,9 +1767,8 @@ private LogBuffer GetBufferForLine (int lineNum) var lastIdx = _lastBufferIndex; if (lastIdx >= 0 && lastIdx < count) { - var buf = list[lastIdx]; + var buf = arr[lastIdx]; if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) - //if (lineNum >= buf.StartLine && lineNum < buf.EndLine) { UpdateLruCache(buf); return buf; @@ -1778,11 +1777,9 @@ private LogBuffer GetBufferForLine (int lineNum) // Layer 1: Adjacent buffer prediction — O(1) for buffer boundary crossings if (lastIdx + 1 < count) { - var next = list[lastIdx + 1]; + var next = arr[lastIdx + 1]; if ((uint)(lineNum - next.StartLine) < (uint)next.LineCount) - //if (lineNum >= next.StartLine && lineNum < next.EndLine) { - _lastBuffer = next; _lastBufferIndex = lastIdx + 1; UpdateLruCache(next); return next; @@ -1791,11 +1788,9 @@ private LogBuffer GetBufferForLine (int lineNum) if (lastIdx - 1 >= 0) { - var prev = list[lastIdx - 1]; + var prev = arr[lastIdx - 1]; if ((uint)(lineNum - prev.StartLine) < (uint)prev.LineCount) - //if (lineNum >= prev.StartLine && lineNum < prev.EndLine) { - _lastBuffer = prev; _lastBufferIndex = lastIdx - 1; UpdateLruCache(prev); return prev; @@ -1805,13 +1800,11 @@ private LogBuffer GetBufferForLine (int lineNum) // Layer 2: Direct mapping guess — O(1) speculative for uniform buffers var guess = lineNum / _maxLinesPerBuffer; - if (guess >= 0 && guess < count) + if ((uint)guess < (uint)count) { - var buf = list[guess]; + var buf = arr[guess]; if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) - //if (lineNum >= buf.StartLine && lineNum < buf.EndLine) { - _lastBuffer = buf; _lastBufferIndex = guess; UpdateLruCache(buf); return buf; @@ -1820,12 +1813,12 @@ private LogBuffer GetBufferForLine (int lineNum) // Layer 3: Branchless binary search with power-of-two strides var step = HighestPowerOfTwo(count); - var idx = (list[step - 1].StartLine <= lineNum) ? count - step : 0; + var idx = (arr[step - 1].StartLine <= lineNum) ? count - step : 0; for (step >>= 1; step > 0; step >>= 1) { var probe = idx + step; - if (probe < count && list[probe - 1].StartLine <= lineNum) + if (probe < count && arr[probe - 1].StartLine <= lineNum) { idx = probe; } @@ -1834,10 +1827,9 @@ private LogBuffer GetBufferForLine (int lineNum) // idx is now the buffer index — verify bounds if (idx < count) { - var buf = list[idx]; + var buf = arr[idx]; if ((uint)(lineNum - buf.StartLine) < (uint)buf.LineCount) { - _lastBuffer = buf; _lastBufferIndex = idx; UpdateLruCache(buf); return buf; From 57fb89b52a5b37b956ffcfd461cd23b25e5e8574 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Fri, 10 Apr 2026 20:39:19 +0200 Subject: [PATCH 09/16] more bookmark painting, manuel / auto display --- .../Classes/Bookmark/BookmarkDataProvider.cs | 22 ++++++ src/LogExpert.Resources/Resources.Designer.cs | 27 +++++++ src/LogExpert.Resources/Resources.resx | 9 +++ .../Bookmark/HighlightBookmarkTriggerTests.cs | 61 +++++++++++++++ .../Controls/LogWindow/LogWindow.cs | 31 ++++++-- .../Dialogs/BookmarkWindow.Designer.cs | 15 +++- src/LogExpert.UI/Dialogs/BookmarkWindow.cs | 75 +++++++++++++------ src/LogExpert.UI/Entities/PaintHelper.cs | 14 ++-- 8 files changed, 215 insertions(+), 39 deletions(-) diff --git a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs index 9c1f99771..3169d97e1 100644 --- a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs +++ b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs @@ -129,6 +129,28 @@ public void RemoveAutoGeneratedBookmarks () } } + /// + /// 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 = []; diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 419c3aeb8..271c4ea92 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. + /// + 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. /// diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 762a37d85..5bdfa6e09 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2157,4 +2157,13 @@ Restart LogExpert to apply changes? Some files could not be migrated: {0} + + Source + + + Auto + + + Manual + \ No newline at end of file diff --git a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs index 805130911..e7a4036eb 100644 --- a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs +++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs @@ -175,6 +175,67 @@ private static (bool NoLed, bool StopTail, bool SetBookmark, string BookmarkComm #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 () { diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index ece2b1b0e..a9a14c96b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -330,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; @@ -6531,10 +6538,10 @@ 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) { @@ -6992,15 +6999,23 @@ 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 { diff --git a/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs b/src/LogExpert.UI/Dialogs/BookmarkWindow.Designer.cs index b0e107f8f..c388abbe6 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(); @@ -32,6 +31,7 @@ private void InitializeComponent() { this.splitContainer1 = new System.Windows.Forms.SplitContainer(); 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); @@ -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 f9908a5dd..131e6f98c 100644 --- a/src/LogExpert.UI/Dialogs/BookmarkWindow.cs +++ b/src/LogExpert.UI/Dialogs/BookmarkWindow.cs @@ -41,7 +41,7 @@ public BookmarkWindow () AutoScaleMode = AutoScaleMode.Dpi; InitializeComponent(); - + contextMenuStrip1.Opening += OnContextMenuStripOpening; bookmarkDataGridView.CellValueNeeded += OnBoomarkDataGridViewCellValueNeeded; bookmarkDataGridView.CellPainting += OnBoomarkDataGridViewCellPainting; @@ -73,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; } } } @@ -116,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(); } @@ -264,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 () @@ -352,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) { @@ -385,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 eb2c8c1a9..a4de73687 100644 --- a/src/LogExpert.UI/Entities/PaintHelper.cs +++ b/src/LogExpert.UI/Entities/PaintHelper.cs @@ -16,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 @@ -71,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); } } } From 739305ea8e144cf34109788c7f21a3db2a35b80e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 18:42:30 +0000 Subject: [PATCH 10/16] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 034207e3c..2e91edeff 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-10 18:42:29 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "71ADBC14647A3518D5BCC9B7C457E245ED9BC09361DF86E35F720810C287EB4E", + ["AutoColumnizer.dll"] = "F48123D9E89846FF0672B2F47CFE0E12418677373F24AC61F7150E155B495CE5", ["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"] = "477F78E7C296B170A64784031C8EDFBD2B6DB7E63E5B41810BAF0387E934049B", + ["CsvColumnizer.dll (x86)"] = "477F78E7C296B170A64784031C8EDFBD2B6DB7E63E5B41810BAF0387E934049B", + ["DefaultPlugins.dll"] = "9B82D22E025CA344FE47D09185CA54191DC6C1CC06405E04658FF6313437E850", + ["FlashIconHighlighter.dll"] = "8107A319F59A6E7F06B0105B6CE4ADE8B62F37DD131EABBE6BF7C4380B44AF20", + ["GlassfishColumnizer.dll"] = "F0A142B1F34A6FC32C7D0A1F104BEE5C23DD78C78E1E801090D53FB23B384AD9", + ["JsonColumnizer.dll"] = "D9C9704A03A09D9C60273FE16124F435BED15E398E70C292AE6CB4CCFC453D02", + ["JsonCompactColumnizer.dll"] = "C23385470D55068890D8695D57C989E01359E5F55E49FB6BAB6EBF97AA19D4D4", + ["Log4jXmlColumnizer.dll"] = "27905C281AE041384E47C9DBE2CDB062EC5D58E78EDC3E62ED8991AEF8D96977", + ["LogExpert.Core.dll"] = "F78AC13F28BE126B11B845747B1AF874BFAB6D215673157A5F9F3028C84C0359", + ["LogExpert.Resources.dll"] = "761364E904171DABFEF4E32E0DB5BEDBC7EFD4D32AE5C6C57AEE7CF4FB4BA20F", ["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"] = "CD852D212CBFCDE1F0B81A05B9FF4A778D08E99AD77A408AFADFB9040F4469C4", + ["SftpFileSystem.dll"] = "0BA5ECA87B31E487EC06DD53C5C85066C6CBC78D9723CFAA2058DB6052B32620", + ["SftpFileSystem.dll (x86)"] = "606944338445C0026170B4CF42144F9894100A81256325C02D7490ACC7C7943E", + ["SftpFileSystem.Resources.dll"] = "D3344C671F9A6DE857ABE69DD91FB34B6D777D48F0385AC6BA6B36494F1C3FB2", + ["SftpFileSystem.Resources.dll (x86)"] = "D3344C671F9A6DE857ABE69DD91FB34B6D777D48F0385AC6BA6B36494F1C3FB2", }; } From 5cb1d60b5cb0b498285780a504a1e76e4aad6c23 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Sat, 11 Apr 2026 09:04:08 +0200 Subject: [PATCH 11/16] bookmarks are now also set when a static file is loaded --- src/LogExpert.Resources/Resources.Designer.cs | 18 +++++ src/LogExpert.Resources/Resources.resx | 6 ++ .../Bookmark/HighlightBookmarkTriggerTests.cs | 74 +++++++++++++++++++ .../Controls/LogWindow/LogWindow.cs | 62 ++++++++++++++-- 4 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 271c4ea92..aaadb9568 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -3342,6 +3342,24 @@ 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... {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.resx b/src/LogExpert.Resources/Resources.resx index 5bdfa6e09..7a506b2e8 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2166,4 +2166,10 @@ Restart LogExpert to apply changes? Manual + + Scanning bookmarks... + + + Scanning bookmarks... {0}% + \ No newline at end of file diff --git a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs index e7a4036eb..831d94c97 100644 --- a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs +++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs @@ -669,4 +669,78 @@ public void Bookmark_JsonDeserialize_DefaultsToManual () } #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.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index a9a14c96b..0b01a4b51 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -887,6 +887,8 @@ private void OnLogFileReaderFinishedLoading (object sender, EventArgs e) } HandleChangedFilterList(); + + _ = Invoke(new MethodInvoker(RunHighlightBookmarkScan)); } _reloadMemento = null; @@ -3135,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) @@ -3186,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) @@ -3659,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) @@ -6291,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, @@ -7051,13 +7063,47 @@ 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. @@ -7096,7 +7142,7 @@ private void RunHighlightBookmarkScan () var lineCount = _logFileReader.LineCount; var fileName = FileName; - StatusLineText("Scanning bookmarks..."); + StatusLineText(Resources.LogWindow_UI_StatusLineText_ScanningBookmarks); _progressEventArgs.MinValue = 0; _progressEventArgs.MaxValue = lineCount; _progressEventArgs.Value = 0; @@ -7116,7 +7162,7 @@ private void RunHighlightBookmarkScan () if (lineCount > 0) { var pct = (int)((long)currentLine * 100 / lineCount); - StatusLineText($"Scanning bookmarks... {pct}%"); + StatusLineText(string.Format(LogExpert.Resources.LogWindow_UI_StatusLineText_ScanningBookmarksPct, pct)); } }); } From af81f8bbad84aa8d1ed7b34ce485736055c3ba82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 07:06:37 +0000 Subject: [PATCH 12/16] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 2e91edeff..0f197c7d9 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-10 18:42:29 UTC + /// Generated: 2026-04-11 07:06:36 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "F48123D9E89846FF0672B2F47CFE0E12418677373F24AC61F7150E155B495CE5", + ["AutoColumnizer.dll"] = "AFE0267CF3C6508092C46E83D9A8CEF142404815B1D8B21BE6D9BD62215A4B19", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "477F78E7C296B170A64784031C8EDFBD2B6DB7E63E5B41810BAF0387E934049B", - ["CsvColumnizer.dll (x86)"] = "477F78E7C296B170A64784031C8EDFBD2B6DB7E63E5B41810BAF0387E934049B", - ["DefaultPlugins.dll"] = "9B82D22E025CA344FE47D09185CA54191DC6C1CC06405E04658FF6313437E850", - ["FlashIconHighlighter.dll"] = "8107A319F59A6E7F06B0105B6CE4ADE8B62F37DD131EABBE6BF7C4380B44AF20", - ["GlassfishColumnizer.dll"] = "F0A142B1F34A6FC32C7D0A1F104BEE5C23DD78C78E1E801090D53FB23B384AD9", - ["JsonColumnizer.dll"] = "D9C9704A03A09D9C60273FE16124F435BED15E398E70C292AE6CB4CCFC453D02", - ["JsonCompactColumnizer.dll"] = "C23385470D55068890D8695D57C989E01359E5F55E49FB6BAB6EBF97AA19D4D4", - ["Log4jXmlColumnizer.dll"] = "27905C281AE041384E47C9DBE2CDB062EC5D58E78EDC3E62ED8991AEF8D96977", - ["LogExpert.Core.dll"] = "F78AC13F28BE126B11B845747B1AF874BFAB6D215673157A5F9F3028C84C0359", - ["LogExpert.Resources.dll"] = "761364E904171DABFEF4E32E0DB5BEDBC7EFD4D32AE5C6C57AEE7CF4FB4BA20F", + ["CsvColumnizer.dll"] = "7E958380E3D46F2DBA5E9FEC70AF48EA4E24EE6F2E323D6E7D67DA408FDFCBED", + ["CsvColumnizer.dll (x86)"] = "7E958380E3D46F2DBA5E9FEC70AF48EA4E24EE6F2E323D6E7D67DA408FDFCBED", + ["DefaultPlugins.dll"] = "4DB3C57F57BC61A406CF16DB8605E47985CFEC206FCDF61CB187915223CB6CDD", + ["FlashIconHighlighter.dll"] = "FB71A7D81EFFC84F145C9D0CF47DEB8E050F27FE004508C271284F08BB2483C5", + ["GlassfishColumnizer.dll"] = "2F7757820F89B96670401FE18AD03ACF691470939D6013BCEFD7EE85868ED27F", + ["JsonColumnizer.dll"] = "F04CA49D21199FB8C17220A81CFB73928918F493CAC392F19CB8DF45D2D94091", + ["JsonCompactColumnizer.dll"] = "E711AE98471308B99079A413A38EE818E62D389D504D2AD50A3916B835F3E062", + ["Log4jXmlColumnizer.dll"] = "90B50F1D347443263DEADE97EBD3CBF8A7DBA685EFC6C01C5EA3C87B0FAB1C76", + ["LogExpert.Core.dll"] = "EC1F98FD423ABA068B07F01803A526C77C0CAB449A18D50939D4CE02A7C97E39", + ["LogExpert.Resources.dll"] = "B4A70B0D9AA0C9DDD686786358A6B5D935B3ABBBE80C2CB8028528CDDE37937D", ["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"] = "CD852D212CBFCDE1F0B81A05B9FF4A778D08E99AD77A408AFADFB9040F4469C4", - ["SftpFileSystem.dll"] = "0BA5ECA87B31E487EC06DD53C5C85066C6CBC78D9723CFAA2058DB6052B32620", - ["SftpFileSystem.dll (x86)"] = "606944338445C0026170B4CF42144F9894100A81256325C02D7490ACC7C7943E", - ["SftpFileSystem.Resources.dll"] = "D3344C671F9A6DE857ABE69DD91FB34B6D777D48F0385AC6BA6B36494F1C3FB2", - ["SftpFileSystem.Resources.dll (x86)"] = "D3344C671F9A6DE857ABE69DD91FB34B6D777D48F0385AC6BA6B36494F1C3FB2", + ["RegexColumnizer.dll"] = "7092F36B9828EDBEE5D5A8873E05E1BF4DA7155C230068D1257D81F60F758306", + ["SftpFileSystem.dll"] = "B03A98512B718500E273E98190AFACD713AE3FB7A33A6D56E9864DBA2F448A9F", + ["SftpFileSystem.dll (x86)"] = "49E3DAC83EBF059BD5C08984300F083B77324AAA589A7BE726D64A33667402E1", + ["SftpFileSystem.Resources.dll"] = "0535F67C09B93F682FC2B957BC17FF93B9D86BDF6F1AAFE638567D60C980829A", + ["SftpFileSystem.Resources.dll (x86)"] = "0535F67C09B93F682FC2B957BC17FF93B9D86BDF6F1AAFE638567D60C980829A", }; } From e675362d42dc22b1dc83936f6a1b362966979249 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Sat, 11 Apr 2026 09:30:21 +0200 Subject: [PATCH 13/16] resources and optimisations --- src/LogExpert.Resources/Resources.Designer.cs | 11 +- src/LogExpert.Resources/Resources.de.resx | 18 +++ src/LogExpert.Resources/Resources.resx | 5 +- src/LogExpert.Resources/Resources.zh-CN.resx | 18 +++ .../Controls/LogWindow/LogWindow.cs | 118 +++++++++++------- 5 files changed, 120 insertions(+), 50 deletions(-) diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index aaadb9568..074a9a5db 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -281,7 +281,7 @@ public static string BookmarkWindow_UI_DataGridColumn_HeaderText { } /// - /// Looks up a localized string similar to Source. + /// Looks up a localized string similar to Source Highlight Trigger. /// public static string BookmarkWindow_UI_DataGridColumn_HeaderTextSource { get { @@ -3351,6 +3351,15 @@ public static string LogWindow_UI_StatusLineText_ScanningBookmarks { } } + /// + /// 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}%. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 351aae19b..250395802 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 7a506b2e8..baae535db 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2158,7 +2158,7 @@ Restart LogExpert to apply changes? Some files could not be migrated: {0} - Source + Source Highlight Trigger Auto @@ -2172,4 +2172,7 @@ Restart LogExpert to apply changes? 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 9206232aa..2c106f32b 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.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 0b01a4b51..11de41016 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -7079,8 +7079,8 @@ public void SetBookmarkFromTrigger (int lineNum, string comment) } /// - /// Returns the SearchText of the first matching highlight entry with IsSetBookmark for the given line. - /// Used to set SourceHighlightText on trigger-created bookmarks. + /// 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) { @@ -7149,70 +7149,92 @@ private void RunHighlightBookmarkScan () _progressEventArgs.Visible = true; SendProgressBarUpdate(); - var progress = new Progress(currentLine => + 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) + { + try { - // Marshal progress update to UI thread - if (IsHandleCreated && !IsDisposed) + 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(() => { - _progressEventArgs.Value = currentLine; - SendProgressBarUpdate(); + _ = _bookmarkProvider.AddBookmarks(bookmarks); - if (lineCount > 0) - { - var pct = (int)((long)currentLine * 100 / lineCount); - StatusLineText(string.Format(LogExpert.Resources.LogWindow_UI_StatusLineText_ScanningBookmarksPct, pct)); - } + RefreshAllGrids(); + + _progressEventArgs.Visible = false; + SendProgressBarUpdate(); + StatusLineText(string.Empty); }); } - }); - - _ = Task.Run(() => + } + catch (OperationCanceledException) { - try + // Scan was cancelled — clean up on UI thread + if (IsHandleCreated && !IsDisposed) { - var bookmarks = HighlightBookmarkScanner.Scan(lineCount, i => _logFileReader.GetLogLineMemory(i), entries, fileName, PROGRESS_BAR_MODULO, progress, cts.Token); - - // Marshal bookmark additions to UI thread - if (!cts.Token.IsCancellationRequested && IsHandleCreated && !IsDisposed) + _ = BeginInvoke(() => { - _ = BeginInvoke(() => - { - _ = _bookmarkProvider.AddBookmarks(bookmarks); + _progressEventArgs.Visible = false; + SendProgressBarUpdate(); + StatusLineText(string.Empty); + }); + } + } + finally + { + // Only dispose if this is still the active CTS + if (_highlightBookmarkScanCts == cts) + { + _highlightBookmarkScanCts = null; + } - RefreshAllGrids(); + cts.Dispose(); + } + } - _progressEventArgs.Visible = false; - SendProgressBarUpdate(); - StatusLineText(string.Empty); - }); - } + private void OnHighlightBookmarkScanProgress (int currentLine) + { + try + { + if (_highlightBookmarkScanCts is not { } cts) + { + return; } - catch (OperationCanceledException) + + if (cts.Token.IsCancellationRequested) { - // Scan was cancelled — clean up on UI thread - if (IsHandleCreated && !IsDisposed) - { - _ = BeginInvoke(() => - { - _progressEventArgs.Visible = false; - SendProgressBarUpdate(); - StatusLineText(string.Empty); - }); - } + StatusLineText(Resources.LogWindow_UI_StatusLineText_ScanningBookmarksEnded); + return; } - finally + } + catch (ObjectDisposedException) + { + // CTS was disposed after task completed — progress callback is stale + return; + } + + if (IsHandleCreated && !IsDisposed) + { + _ = BeginInvoke(() => { - // Only dispose if this is still the active CTS - if (_highlightBookmarkScanCts == cts) + _progressEventArgs.Value = currentLine; + SendProgressBarUpdate(); + + var lineCount = _logFileReader?.LineCount ?? 0; + if (lineCount > 0) { - _highlightBookmarkScanCts = null; + var pct = (int)((long)currentLine * 100 / lineCount); + StatusLineText(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineText_ScanningBookmarksPct, pct)); } - - cts.Dispose(); - } - }); + }); + } } /// From 6a5d8937b449506ec274b8805996470b8da324d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 07:32:39 +0000 Subject: [PATCH 14/16] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 0f197c7d9..91d87e0ad 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-11 07:06:36 UTC + /// Generated: 2026-04-11 07:32:38 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "AFE0267CF3C6508092C46E83D9A8CEF142404815B1D8B21BE6D9BD62215A4B19", + ["AutoColumnizer.dll"] = "96F0E1A725BB9A860A71D83F56A073A0E17CC58472730A2E7AD9C94F5768131B", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "7E958380E3D46F2DBA5E9FEC70AF48EA4E24EE6F2E323D6E7D67DA408FDFCBED", - ["CsvColumnizer.dll (x86)"] = "7E958380E3D46F2DBA5E9FEC70AF48EA4E24EE6F2E323D6E7D67DA408FDFCBED", - ["DefaultPlugins.dll"] = "4DB3C57F57BC61A406CF16DB8605E47985CFEC206FCDF61CB187915223CB6CDD", - ["FlashIconHighlighter.dll"] = "FB71A7D81EFFC84F145C9D0CF47DEB8E050F27FE004508C271284F08BB2483C5", - ["GlassfishColumnizer.dll"] = "2F7757820F89B96670401FE18AD03ACF691470939D6013BCEFD7EE85868ED27F", - ["JsonColumnizer.dll"] = "F04CA49D21199FB8C17220A81CFB73928918F493CAC392F19CB8DF45D2D94091", - ["JsonCompactColumnizer.dll"] = "E711AE98471308B99079A413A38EE818E62D389D504D2AD50A3916B835F3E062", - ["Log4jXmlColumnizer.dll"] = "90B50F1D347443263DEADE97EBD3CBF8A7DBA685EFC6C01C5EA3C87B0FAB1C76", - ["LogExpert.Core.dll"] = "EC1F98FD423ABA068B07F01803A526C77C0CAB449A18D50939D4CE02A7C97E39", - ["LogExpert.Resources.dll"] = "B4A70B0D9AA0C9DDD686786358A6B5D935B3ABBBE80C2CB8028528CDDE37937D", + ["CsvColumnizer.dll"] = "DD535C45035E7C3361D81C964F447D0E5F619DFBBDF206B05E7E0D92D9024D83", + ["CsvColumnizer.dll (x86)"] = "DD535C45035E7C3361D81C964F447D0E5F619DFBBDF206B05E7E0D92D9024D83", + ["DefaultPlugins.dll"] = "57DC730E8BFB7741AA099BFD6DD84E8319503674D0635A2C3FA7073E2361DABB", + ["FlashIconHighlighter.dll"] = "BB5629706EC14148BBB5940C4D0EEE401FAA1173AF772C8FBC6673C3C90D6C6A", + ["GlassfishColumnizer.dll"] = "8E21ED585347E2D50850C2E724953416E93BC7460B7D29C32462F49DE78C01DC", + ["JsonColumnizer.dll"] = "5247E386F49DC91AB16FEE985965AA0EDEFD5BBAED9FD815AFF8E2BB194D467F", + ["JsonCompactColumnizer.dll"] = "C6BEC1F45E488C6E65E890C163EA53384E3F57C37D033D0DBE42C489930C9CE9", + ["Log4jXmlColumnizer.dll"] = "5FE3681F2E6B7B694C02C317FC84C63A89533941213859662241F3826D9B2374", + ["LogExpert.Core.dll"] = "D66195F141228D226837C8DF08CB7820C881CA93E8F20BBCC750D54945E72CDD", + ["LogExpert.Resources.dll"] = "39A7981645647A0834D36599EF0F86E78A9725B8981951D88D6997790862DC05", ["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"] = "7092F36B9828EDBEE5D5A8873E05E1BF4DA7155C230068D1257D81F60F758306", - ["SftpFileSystem.dll"] = "B03A98512B718500E273E98190AFACD713AE3FB7A33A6D56E9864DBA2F448A9F", - ["SftpFileSystem.dll (x86)"] = "49E3DAC83EBF059BD5C08984300F083B77324AAA589A7BE726D64A33667402E1", - ["SftpFileSystem.Resources.dll"] = "0535F67C09B93F682FC2B957BC17FF93B9D86BDF6F1AAFE638567D60C980829A", - ["SftpFileSystem.Resources.dll (x86)"] = "0535F67C09B93F682FC2B957BC17FF93B9D86BDF6F1AAFE638567D60C980829A", + ["RegexColumnizer.dll"] = "DB8D502F881C53239202E7CE1442B7C14D0088B3A192DFCBC51E333A5FF7B1CA", + ["SftpFileSystem.dll"] = "7EEAE18AE5ACB3B2C0B7621C16117B72603810C8A03DD8E267E8A9CE0D73E747", + ["SftpFileSystem.dll (x86)"] = "8CB8A1E924B80F74E1B2E39F0316B5A6A52886792664C19BB4889959BF6D9DF8", + ["SftpFileSystem.Resources.dll"] = "65B3031D69EF9AE42A19F784DDDA39B0202566ABED055F04ECF6E38C8D7177F8", + ["SftpFileSystem.Resources.dll (x86)"] = "65B3031D69EF9AE42A19F784DDDA39B0202566ABED055F04ECF6E38C8D7177F8", }; } From 47b67f976f4606f32729b6841345fcd8b62b7949 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Sat, 11 Apr 2026 09:48:27 +0200 Subject: [PATCH 15/16] optimisations --- .../Classes/Bookmark/BookmarkDataProvider.cs | 11 +--- .../Bookmark/HighlightBookmarkScanner.cs | 23 +++---- .../Bookmark/HighlightBookmarkScannerTests.cs | 3 +- .../Controls/BufferedDataGridView.cs | 2 +- .../Controls/LogWindow/LogWindow.cs | 61 ++++++++++--------- 5 files changed, 47 insertions(+), 53 deletions(-) diff --git a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs index 3169d97e1..7432df739 100644 --- a/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs +++ b/src/LogExpert.Core/Classes/Bookmark/BookmarkDataProvider.cs @@ -107,14 +107,9 @@ public void RemoveAutoGeneratedBookmarks () lock (_bookmarkListLock) { - List keysToRemove = []; - foreach (var kvp in BookmarkList) - { - if (kvp.Value.IsAutoGenerated) - { - keysToRemove.Add(kvp.Key); - } - } + List keysToRemove = [.. BookmarkList + .Where(kvp => kvp.Value.IsAutoGenerated) + .Select(kvp => kvp.Key)]; foreach (var key in keysToRemove) { diff --git a/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs index 44b963cac..afbbc1fda 100644 --- a/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs +++ b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs @@ -1,3 +1,5 @@ +using System.Text; + using ColumnizerLib; using LogExpert.Core.Classes.Highlight; @@ -79,26 +81,21 @@ public static class HighlightBookmarkScanner private static (bool SetBookmark, string BookmarkComment, string SourceHighlightText) GetBookmarkAction (ITextValueMemory line, List bookmarkEntries) { var setBookmark = false; - var bookmarkComment = string.Empty; + var bookmarkCommentBuilder = new StringBuilder(); var sourceHighlightText = string.Empty; - foreach (var entry in bookmarkEntries) + foreach (var entry in bookmarkEntries.Where(entry => CheckHighlightEntryMatch(entry, line))) { - if (CheckHighlightEntryMatch(entry, line)) - { - setBookmark = true; - sourceHighlightText = entry.SearchText; + setBookmark = true; + sourceHighlightText = entry.SearchText; - if (!string.IsNullOrEmpty(entry.BookmarkComment)) - { - bookmarkComment += entry.BookmarkComment + "\r\n"; - } + if (!string.IsNullOrEmpty(entry.BookmarkComment)) + { + _ = bookmarkCommentBuilder.Append(entry.BookmarkComment).Append("\r\n"); } } - bookmarkComment = bookmarkComment.TrimEnd('\r', '\n'); - - return (setBookmark, bookmarkComment, sourceHighlightText); + return (setBookmark, bookmarkCommentBuilder.ToString().TrimEnd('\r', '\n'), sourceHighlightText); } /// diff --git a/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs index 67a2b6875..d3f2e570d 100644 --- a/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs +++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkScannerTests.cs @@ -213,7 +213,8 @@ public void Scan_CancellationRequested_ThrowsOperationCanceled () { new() { SearchText = "ERROR", IsSetBookmark = true } }; - var cts = new CancellationTokenSource(); + + using var cts = new CancellationTokenSource(); cts.Cancel(); // Act & Assert diff --git a/src/LogExpert.UI/Controls/BufferedDataGridView.cs b/src/LogExpert.UI/Controls/BufferedDataGridView.cs index 920bd0a04..5ef1f004c 100644 --- a/src/LogExpert.UI/Controls/BufferedDataGridView.cs +++ b/src/LogExpert.UI/Controls/BufferedDataGridView.cs @@ -170,7 +170,7 @@ protected override void OnPaint (PaintEventArgs e) { base.OnPaint(e); } - catch (Exception innerEx) + catch (InvalidOperationException innerEx) { _logger.Error($"Base paint also failed. {innerEx}"); } diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 11de41016..73c388be4 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -7155,47 +7155,48 @@ private void RunHighlightBookmarkScan () private void ExecuteHighlightBookmarkScan (int lineCount, List entries, string fileName, IProgress progress, CancellationTokenSource cts) { - try + using (cts) { - 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) + try { - _ = BeginInvoke(() => + 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) { - _ = _bookmarkProvider.AddBookmarks(bookmarks); + _ = BeginInvoke(() => + { + _ = _bookmarkProvider.AddBookmarks(bookmarks); - RefreshAllGrids(); + RefreshAllGrids(); - _progressEventArgs.Visible = false; - SendProgressBarUpdate(); - StatusLineText(string.Empty); - }); + _progressEventArgs.Visible = false; + SendProgressBarUpdate(); + StatusLineText(string.Empty); + }); + } } - } - catch (OperationCanceledException) - { - // Scan was cancelled — clean up on UI thread - if (IsHandleCreated && !IsDisposed) + catch (OperationCanceledException) { - _ = BeginInvoke(() => + // Scan was cancelled — clean up on UI thread + if (IsHandleCreated && !IsDisposed) { - _progressEventArgs.Visible = false; - SendProgressBarUpdate(); - StatusLineText(string.Empty); - }); + _ = BeginInvoke(() => + { + _progressEventArgs.Visible = false; + SendProgressBarUpdate(); + StatusLineText(string.Empty); + }); + } } - } - finally - { - // Only dispose if this is still the active CTS - if (_highlightBookmarkScanCts == cts) + finally { - _highlightBookmarkScanCts = null; + // Only dispose if this is still the active CTS + if (_highlightBookmarkScanCts == cts) + { + _highlightBookmarkScanCts = null; + } } - - cts.Dispose(); } } From f45fa97832ad4cde11b0c3abf5444dfab6739185 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 07:50:47 +0000 Subject: [PATCH 16/16] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 91d87e0ad..3e1c6d402 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-11 07:32:38 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"] = "96F0E1A725BB9A860A71D83F56A073A0E17CC58472730A2E7AD9C94F5768131B", + ["AutoColumnizer.dll"] = "D36D2E597CB0013725F96DBCB7BBF5D7507DE06EFBFDB7052CA8A578FC73A2D0", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "DD535C45035E7C3361D81C964F447D0E5F619DFBBDF206B05E7E0D92D9024D83", - ["CsvColumnizer.dll (x86)"] = "DD535C45035E7C3361D81C964F447D0E5F619DFBBDF206B05E7E0D92D9024D83", - ["DefaultPlugins.dll"] = "57DC730E8BFB7741AA099BFD6DD84E8319503674D0635A2C3FA7073E2361DABB", - ["FlashIconHighlighter.dll"] = "BB5629706EC14148BBB5940C4D0EEE401FAA1173AF772C8FBC6673C3C90D6C6A", - ["GlassfishColumnizer.dll"] = "8E21ED585347E2D50850C2E724953416E93BC7460B7D29C32462F49DE78C01DC", - ["JsonColumnizer.dll"] = "5247E386F49DC91AB16FEE985965AA0EDEFD5BBAED9FD815AFF8E2BB194D467F", - ["JsonCompactColumnizer.dll"] = "C6BEC1F45E488C6E65E890C163EA53384E3F57C37D033D0DBE42C489930C9CE9", - ["Log4jXmlColumnizer.dll"] = "5FE3681F2E6B7B694C02C317FC84C63A89533941213859662241F3826D9B2374", - ["LogExpert.Core.dll"] = "D66195F141228D226837C8DF08CB7820C881CA93E8F20BBCC750D54945E72CDD", - ["LogExpert.Resources.dll"] = "39A7981645647A0834D36599EF0F86E78A9725B8981951D88D6997790862DC05", + ["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"] = "DB8D502F881C53239202E7CE1442B7C14D0088B3A192DFCBC51E333A5FF7B1CA", - ["SftpFileSystem.dll"] = "7EEAE18AE5ACB3B2C0B7621C16117B72603810C8A03DD8E267E8A9CE0D73E747", - ["SftpFileSystem.dll (x86)"] = "8CB8A1E924B80F74E1B2E39F0316B5A6A52886792664C19BB4889959BF6D9DF8", - ["SftpFileSystem.Resources.dll"] = "65B3031D69EF9AE42A19F784DDDA39B0202566ABED055F04ECF6E38C8D7177F8", - ["SftpFileSystem.Resources.dll (x86)"] = "65B3031D69EF9AE42A19F784DDDA39B0202566ABED055F04ECF6E38C8D7177F8", + ["RegexColumnizer.dll"] = "F13240DC73541B68E24A1EED63E19052AD9D7FAD612DF644ACC053D023E3156A", + ["SftpFileSystem.dll"] = "1F6D11FA4C748E014A3A15A20EFFF4358AD14C391821F45F4AECA6D9A3D47C60", + ["SftpFileSystem.dll (x86)"] = "6A8CC68ED4BBED8838FCA45E74AA31F73F0BFEAFD400C278FBC48ED2FF8FF180", + ["SftpFileSystem.Resources.dll"] = "64686BBECECB92AA5A7B13EF98C1CDCC1D2ECA4D9BBE1A1B367A2CA39BB5B6BD", + ["SftpFileSystem.Resources.dll (x86)"] = "64686BBECECB92AA5A7B13EF98C1CDCC1D2ECA4D9BBE1A1B367A2CA39BB5B6BD", }; }