diff --git a/src/LogExpert.Configuration/ConfigManager.cs b/src/LogExpert.Configuration/ConfigManager.cs index eb1d8440..702ba130 100644 --- a/src/LogExpert.Configuration/ConfigManager.cs +++ b/src/LogExpert.Configuration/ConfigManager.cs @@ -328,7 +328,6 @@ public void RemoveFromFileHistory (string fileName) Save(SettingsFlags.FileHistory); } - public void ClearLastOpenFilesList () { lock (_loadSaveLock) @@ -596,6 +595,9 @@ UnauthorizedAccessException or private static Settings InitializeSettings (Settings settings) { + // Apply any pending schema migrations before any consumer reads the settings. + _ = LegacyPreferencesMigrator.Migrate(settings); + settings.Preferences ??= new Preferences(); settings.Preferences.ToolEntries ??= []; settings.Preferences.ColumnizerMaskList ??= []; diff --git a/src/LogExpert.Configuration/LegacyPreferencesMigrator.cs b/src/LogExpert.Configuration/LegacyPreferencesMigrator.cs new file mode 100644 index 00000000..cb31a16d --- /dev/null +++ b/src/LogExpert.Configuration/LegacyPreferencesMigrator.cs @@ -0,0 +1,58 @@ +using LogExpert.Core.Config; + +namespace LogExpert.Configuration; + +/// +/// Applies one-shot, in-memory migrations to a freshly deserialised object so that +/// older settings files behave equivalently under newer schema versions. Idempotent: calling it on already +/// up-to-date settings is a no-op. +/// +public static class LegacyPreferencesMigrator +{ + /// Current schema version. Bumped whenever a new migration step is added. + public const int CURRENT_SETTINGS_VERSION = 1; + + /// + /// Migrates the given in place. Returns if any + /// migration step was applied (the caller may use this signal to persist the upgraded settings). + /// + public static bool Migrate (Settings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var changed = false; + + if (settings.SettingsVersion < 1) + { + MigrateToV1(settings); + settings.SettingsVersion = 1; + changed = true; + } + + return changed; + } + + private static void MigrateToV1 (Settings settings) + { + // Existing ColumnizerMaskEntry rows pre-date the per-row Type field — they were regex-only. + // Their default-loaded value would be Glob, which would silently change behaviour. Rewrite to Regex. + if (settings.Preferences?.ColumnizerMaskList != null) + { + foreach (var entry in settings.Preferences.ColumnizerMaskList.Where(entry => entry != null)) + { + entry.Type = MaskType.Regex; + } + } + + // Preserve the deprecated MaskPrio bool's intent on the new enum, but only if the enum is still + // at its default — otherwise the user has already chosen. +#pragma warning disable CS0618 // Migrating away from MaskPrio + if (settings.Preferences != null + && settings.Preferences.ColumnizerSelectionPriority == ColumnizerSelectionPriority.HistoryThenMask + && settings.Preferences.MaskPrio) + { + settings.Preferences.ColumnizerSelectionPriority = ColumnizerSelectionPriority.MaskThenHistory; + } +#pragma warning restore CS0618 + } +} diff --git a/src/LogExpert.Core/Classes/Columnizer/ColumnizerMaskMatcher.cs b/src/LogExpert.Core/Classes/Columnizer/ColumnizerMaskMatcher.cs new file mode 100644 index 00000000..a093b643 --- /dev/null +++ b/src/LogExpert.Core/Classes/Columnizer/ColumnizerMaskMatcher.cs @@ -0,0 +1,69 @@ +using System.Text; +using System.Text.RegularExpressions; + +using LogExpert.Core.Config; + +namespace LogExpert.Core.Classes.Columnizer; + +/// +/// Pure mask-matching for . Supports glob (*, ?) and +/// .NET regular expression patterns. Match is case-insensitive (file names on Windows are case-insensitive). +/// +/// +/// The matcher never throws — malformed input returns . Glob translation rules: +/// +/// *.* +/// ?. (single character) +/// Every other character is regex-escaped +/// Result is anchored with ^…$ +/// +/// +public static class ColumnizerMaskMatcher +{ + private const RegexOptions OPTIONS = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + + public static bool Matches (ColumnizerMaskEntry entry, string fileName) + { + if (entry == null || string.IsNullOrEmpty(entry.Mask) || string.IsNullOrEmpty(fileName)) + { + return false; + } + + var pattern = entry.Type == MaskType.Glob + ? GlobToRegex(entry.Mask) + : entry.Mask; + + try + { + return Regex.IsMatch(fileName, pattern, OPTIONS); + } + catch (ArgumentException) + { + // Malformed regex (user-supplied) — treat as non-match rather than throwing. + return false; + } + catch (RegexMatchTimeoutException) + { + return false; + } + } + + private static string GlobToRegex (string glob) + { + var sb = new StringBuilder(glob.Length + 4); + _ = sb.Append('^'); + + foreach (var ch in glob) + { + _ = ch switch + { + '*' => sb.Append(".*"), + '?' => sb.Append('.'), + _ => sb.Append(Regex.Escape(ch.ToString())), + }; + } + + _ = sb.Append('$'); + return sb.ToString(); + } +} diff --git a/src/LogExpert.Core/Classes/Columnizer/ColumnizerPicker.cs b/src/LogExpert.Core/Classes/Columnizer/ColumnizerPicker.cs index 3423404f..75349176 100644 --- a/src/LogExpert.Core/Classes/Columnizer/ColumnizerPicker.cs +++ b/src/LogExpert.Core/Classes/Columnizer/ColumnizerPicker.cs @@ -18,7 +18,7 @@ public static class ColumnizerPicker /// Cannot be null. /// The list of available columnizers to search. Cannot be null. /// The first columnizer from the list whose name matches the specified value; otherwise, null if no match is found. - public static ILogLineMemoryColumnizer FindMemorColumnizerByName (string name, IList list) + public static ILogLineMemoryColumnizer FindMemoryColumnizerByName (string name, IList list) { ArgumentNullException.ThrowIfNull(name, nameof(name)); ArgumentNullException.ThrowIfNull(list, nameof(list)); diff --git a/src/LogExpert.Core/Classes/Columnizer/ColumnizerResolver.cs b/src/LogExpert.Core/Classes/Columnizer/ColumnizerResolver.cs new file mode 100644 index 00000000..0ab64bb1 --- /dev/null +++ b/src/LogExpert.Core/Classes/Columnizer/ColumnizerResolver.cs @@ -0,0 +1,81 @@ +using ColumnizerLib; + +using LogExpert.Core.Config; + +namespace LogExpert.Core.Classes.Columnizer; + +/// +/// Pure precedence-chain resolver that picks a columnizer for a given file from up to four sources: per-file +/// persistence, columnizer history, columnizer-mask list, and AutoPick. +/// +/// +/// The exact order of consultation is controlled by . AutoPick fires only when +/// every other source produced ; it never outranks an explicit Mask, History, or Persistence hit. +/// Stale Mask entries — those whose is not registered — are +/// skipped (an optional callback is invoked once per skipped entry) and resolution continues with the next entry in the +/// list. +/// +public static class ColumnizerResolver +{ + /// + /// Returns the winning columnizer for the inputs, or if no source produced a match. + /// + public static ILogLineMemoryColumnizer? Resolve (ResolveInputs inputs) + { + ArgumentNullException.ThrowIfNull(inputs); + + // Look up a columnizer by name with "no signal" semantics — returns null when the name is + // missing OR not registered, so the precedence chain falls through to the next source. + ILogLineMemoryColumnizer? byName (string? name) => string.IsNullOrEmpty(name) ? null : ColumnizerPicker.FindMemoryColumnizerByName(name, inputs.Registered); + ILogLineMemoryColumnizer? mask () => TryGetMaskColumnizer(inputs.MaskList, inputs.ShortFileName, inputs.Registered, inputs.OnStaleMaskEntry); + ILogLineMemoryColumnizer? history () => byName(inputs.HistoryLookup?.Invoke(inputs.FileName)); + ILogLineMemoryColumnizer? persistence () => byName(inputs.PersistenceColumnizerName); + + var winner = inputs.Priority switch + { + ColumnizerSelectionPriority.MaskThenHistory => persistence() ?? mask() ?? history(), + ColumnizerSelectionPriority.MaskOverridesPersistence => mask() ?? persistence() ?? history(), + ColumnizerSelectionPriority.HistoryThenMask => persistence() ?? history() ?? mask(), + _ => persistence() ?? history() ?? mask(), + }; + + return winner ?? inputs.AutoPick?.Invoke(); + } + + /// + /// Iterates the mask list and returns the first non-stale match. Entries whose columnizer is not registered invoke + /// and the iteration continues. Never throws. + /// + public static ILogLineMemoryColumnizer? TryGetMaskColumnizer ( + IReadOnlyList maskList, + string shortFileName, + IList registered, + Action? onStale = null) + { + if (maskList == null || maskList.Count == 0 || string.IsNullOrEmpty(shortFileName)) + { + return null; + } + + foreach (var entry in maskList) + { + if (!ColumnizerMaskMatcher.Matches(entry, shortFileName)) + { + continue; + } + + + // FindMemoryColumnizerByName returns null when the name isn't registered, which is + // exactly the "stale entry" signal we need. + var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(entry.ColumnizerName, registered); + if (columnizer != null) + { + return columnizer; + } + + onStale?.Invoke(entry); + } + + return null; + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Columnizer/ResolveInputs.cs b/src/LogExpert.Core/Classes/Columnizer/ResolveInputs.cs new file mode 100644 index 00000000..a9f5069c --- /dev/null +++ b/src/LogExpert.Core/Classes/Columnizer/ResolveInputs.cs @@ -0,0 +1,36 @@ +using ColumnizerLib; + +using LogExpert.Core.Config; + +namespace LogExpert.Core.Classes.Columnizer; + +/// +/// Inputs to . Designed so the module is exercisable from unit tests with no +/// dependency on settings storage, plugin registry singletons, or UI. +/// +public sealed class ResolveInputs +{ + public ColumnizerSelectionPriority Priority { get; init; } + + /// The full path or identifier of the file being opened. + public string FileName { get; init; } = string.Empty; + + /// The short (filename-only) form of , used for mask matching. + public string ShortFileName { get; init; } = string.Empty; + + public IReadOnlyList MaskList { get; init; } = []; + + /// Lookup of a saved history columnizer name for . May be . + public Func? HistoryLookup { get; init; } + + /// Columnizer name supplied by the per-file persistence (.lxp). May be . + public string? PersistenceColumnizerName { get; init; } + + /// AutoPick callback (e.g. content-based detection). May be . + public Func? AutoPick { get; init; } + + public IList Registered { get; init; } = []; + + /// Invoked once for each Mask entry that matched but referenced a missing columnizer. + public Action? OnStaleMaskEntry { get; init; } +} diff --git a/src/LogExpert.Core/Config/ColumnizerMaskEntry.cs b/src/LogExpert.Core/Config/ColumnizerMaskEntry.cs index 9e11b1de..3cb32225 100644 --- a/src/LogExpert.Core/Config/ColumnizerMaskEntry.cs +++ b/src/LogExpert.Core/Config/ColumnizerMaskEntry.cs @@ -6,4 +6,10 @@ public class ColumnizerMaskEntry public string ColumnizerName { get; set; } public string Mask { get; set; } + + /// + /// How is interpreted. Defaults to for new entries; + /// the settings migrator rewrites pre-1.21 entries to to preserve behaviour. + /// + public MaskType Type { get; set; } = MaskType.Glob; } \ No newline at end of file diff --git a/src/LogExpert.Core/Config/ColumnizerSelectionPriority.cs b/src/LogExpert.Core/Config/ColumnizerSelectionPriority.cs new file mode 100644 index 00000000..7d7a5910 --- /dev/null +++ b/src/LogExpert.Core/Config/ColumnizerSelectionPriority.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace LogExpert.Core.Config; + +/// +/// Controls the precedence order used by the columnizer resolver when multiple sources +/// (per-file persistence, history, mask list) could supply a columnizer for a file. +/// +[JsonConverter(typeof(StringEnumConverter))] +public enum ColumnizerSelectionPriority +{ + /// Persistence → History → Mask → AutoPick (default — preserves legacy behaviour). + HistoryThenMask = 0, + + /// Persistence → Mask → History → AutoPick. + MaskThenHistory = 1, + + /// Mask → Persistence → History → AutoPick. A matching mask outranks the saved .lxp columnizer. + MaskOverridesPersistence = 2, +} \ No newline at end of file diff --git a/src/LogExpert.Core/Config/ControlCharSettings.cs b/src/LogExpert.Core/Config/ControlCharSettings.cs index 8a066476..a379efa0 100644 --- a/src/LogExpert.Core/Config/ControlCharSettings.cs +++ b/src/LogExpert.Core/Config/ControlCharSettings.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; using System.Drawing; -using System.Linq; using Newtonsoft.Json; namespace LogExpert.Core.Config; +[Serializable] public sealed class ControlCharSettings { public bool Substitute { get; set; } @@ -31,9 +30,10 @@ public ControlCharStyle Style internal static HashSet BuildNonWhitespacePreset () { - return Enumerable.Range(0x00, 0x20) - .Where(c => c is not 0x09 and not 0x0A and not 0x0D) - .Append(0x7F) - .ToHashSet(); + return + [ + .. Enumerable.Range(0x00, 0x20).Where(c => c is not 0x09 and not 0x0A and not 0x0D), + 0x7F, + ]; } } diff --git a/src/LogExpert.Core/Config/ControlCharStyle.cs b/src/LogExpert.Core/Config/ControlCharStyle.cs index 5395b219..573533bf 100644 --- a/src/LogExpert.Core/Config/ControlCharStyle.cs +++ b/src/LogExpert.Core/Config/ControlCharStyle.cs @@ -1,5 +1,9 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + namespace LogExpert.Core.Config; +[JsonConverter(typeof(StringEnumConverter))] public enum ControlCharStyle { ControlPictures = 0, diff --git a/src/LogExpert.Core/Config/MaskType.cs b/src/LogExpert.Core/Config/MaskType.cs new file mode 100644 index 00000000..9e045aa1 --- /dev/null +++ b/src/LogExpert.Core/Config/MaskType.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace LogExpert.Core.Config; + +/// +/// Determines how a string is interpreted when matching file names. +/// +/// Serialized by name (e.g. "Glob"/"Regex"); the converter still reads legacy integer values. +[JsonConverter(typeof(StringEnumConverter))] +public enum MaskType +{ + /// Glob pattern using * and ? wildcards (default for new entries). + Glob = 0, + + /// .NET regular expression syntax (legacy default; preserved by migration for pre-existing entries). + Regex = 1, +} diff --git a/src/LogExpert.Core/Config/MultiFileOption.cs b/src/LogExpert.Core/Config/MultiFileOption.cs index 7dce5106..05af05d2 100644 --- a/src/LogExpert.Core/Config/MultiFileOption.cs +++ b/src/LogExpert.Core/Config/MultiFileOption.cs @@ -1,11 +1,13 @@ -using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace LogExpert.Core.Config; [Serializable] +[JsonConverter(typeof(StringEnumConverter))] public enum MultiFileOption { - SingleFiles, - MultiFile, - Ask + SingleFiles = 0, + MultiFile = 1, + Ask = 2 } \ No newline at end of file diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs index 152a202c..61668708 100644 --- a/src/LogExpert.Core/Config/Preferences.cs +++ b/src/LogExpert.Core/Config/Preferences.cs @@ -1,7 +1,9 @@ using System.Drawing; +using System.Runtime.Versioning; using LogExpert.Core.Entities; using LogExpert.Core.Enums; +using LogExpert.Core.Helpers; namespace LogExpert.Core.Config; @@ -131,8 +133,20 @@ public List HilightGroupList public int MaximumFilterEntriesDisplayed { get; set; } = 20; + /// + /// Obsolete: replaced by . Will be removed in 1.50. During settings load, + /// migrates a true value here to + /// on the new property. + /// + [Obsolete("Replaced by ColumnizerSelectionPriority; will be removed in 1.50.")] public bool MaskPrio { get; set; } + /// + /// Controls the precedence order used to resolve a columnizer for a newly opened file. + /// + [Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public ColumnizerSelectionPriority ColumnizerSelectionPriority { get; set; } = ColumnizerSelectionPriority.HistoryThenMask; + public bool AutoPick { get; set; } //TODO Refactor Enum @@ -170,7 +184,16 @@ public List HilightGroupList [System.Text.Json.Serialization.JsonIgnore] [Newtonsoft.Json.JsonIgnore] - public Font Font { get; set; } + [SupportedOSPlatform("windows")] + public Font Font + { + // Lazily materialize from FontString so callers that build Preferences directly (e.g. tests + // that bypass ConfigManager.InitializeFont) get a usable Font without manual setup. + // ConfigManager.InitializeFont still goes through the setter to install the canonical + // instance during real config load. + get => field ??= FontHelper.ParseFontStringOrDefault(FontString); + set; + } [Obsolete("This setting is no longer used and will be removed in version 1.50. The 'FontString' will be used for Importing / Exporting the Font")] public float FontSize { get => field; set => field = MathF.Round(value, 1); } = 9.0f; diff --git a/src/LogExpert.Core/Config/SessionSaveLocation.cs b/src/LogExpert.Core/Config/SessionSaveLocation.cs index 2759cb19..bc394a4e 100644 --- a/src/LogExpert.Core/Config/SessionSaveLocation.cs +++ b/src/LogExpert.Core/Config/SessionSaveLocation.cs @@ -1,25 +1,27 @@ -using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace LogExpert.Core.Config; [Serializable] +[JsonConverter(typeof(StringEnumConverter))] public enum SessionSaveLocation { //Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + Path.DirectorySeparatorChar + "LogExpert" /// /// /// - DocumentsDir, + DocumentsDir = 0, //same directory as the logfile - SameDir, - //uses configured folder to save the session files + SameDir = 1, + //uses configured folder to save the session files /// /// /// - OwnDir, + OwnDir = 2, /// /// /// - ApplicationStartupDir, - LoadedSessionFile + ApplicationStartupDir = 3, + LoadedSessionFile = 4 } \ No newline at end of file diff --git a/src/LogExpert.Core/Config/Settings.cs b/src/LogExpert.Core/Config/Settings.cs index 73988b4d..a9d62ab6 100644 --- a/src/LogExpert.Core/Config/Settings.cs +++ b/src/LogExpert.Core/Config/Settings.cs @@ -11,6 +11,12 @@ public class Settings { public Preferences Preferences { get; set; } = new(); + /// + /// Settings schema version. Incremented when introduces a + /// migration step. Pre-existing settings files have version 0. + /// + public int SettingsVersion { get; set; } + public RegexHistory RegexHistory { get; set; } = new(); public bool AlwaysOnTop { get; set; } diff --git a/src/LogExpert.Core/Enums/DragOrientations.cs b/src/LogExpert.Core/Enums/DragOrientations.cs index 14a00a37..15d525b2 100644 --- a/src/LogExpert.Core/Enums/DragOrientations.cs +++ b/src/LogExpert.Core/Enums/DragOrientations.cs @@ -1,8 +1,12 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + namespace LogExpert.Core.Enums; +[JsonConverter(typeof(StringEnumConverter))] public enum DragOrientations { - Horizontal, - Vertical, - InvertedVertical + Horizontal = 0, + Vertical = 1, + InvertedVertical = 2 } diff --git a/src/LogExpert.Core/Enums/ReaderType.cs b/src/LogExpert.Core/Enums/ReaderType.cs index a16c38a5..facd26c4 100644 --- a/src/LogExpert.Core/Enums/ReaderType.cs +++ b/src/LogExpert.Core/Enums/ReaderType.cs @@ -1,23 +1,27 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + namespace LogExpert.Core.Enums; /// /// Defines the available stream reader implementations. /// +[JsonConverter(typeof(StringEnumConverter))] public enum ReaderType { /// /// Direct-read implementation: reads decoded chars directly into pooled blocks via /// StreamReader.Read(char[], offset, count), eliminating per-line string allocation. /// - SystemDirect, + SystemDirect = 0, /// /// Legacy reader implementation (original). /// - Legacy, + Legacy = 1, /// /// System.IO.StreamReader based implementation. /// - System + System = 2 } diff --git a/src/LogExpert.Core/Helpers/FontHelper.cs b/src/LogExpert.Core/Helpers/FontHelper.cs new file mode 100644 index 00000000..34f871b0 --- /dev/null +++ b/src/LogExpert.Core/Helpers/FontHelper.cs @@ -0,0 +1,29 @@ +using System.Drawing; +using System.Runtime.Versioning; + +namespace LogExpert.Core.Helpers; + +public static class FontHelper +{ + [SupportedOSPlatform("windows")] + public static Font ParseFontStringOrDefault (string fontString) + { + if (!string.IsNullOrWhiteSpace(fontString)) + { + try + { + var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(Font)); + if (converter.ConvertFromInvariantString(fontString) is Font parsed) + { + return parsed; + } + } + catch (Exception ex) when (ex is NotSupportedException or ArgumentException or FormatException) + { + // Fall back to the default below. + } + } + + return new Font(FontFamily.GenericMonospace.Name, 9f); + } +} diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 7821b179..4d43832d 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -5545,6 +5545,33 @@ public static string SettingsDialog_UI_CouldNotCreatePortableMode { } } + /// + /// Looks up a localized string similar to Type. + /// + public static string SettingsDialog_UI_DataGridView_columnHeaderColumnizerMaskType { + get { + return ResourceManager.GetString("SettingsDialog_UI_DataGridView_columnHeaderColumnizerMaskType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Columnizer '{0}' is not installed — this entry will be skipped.. + /// + public static string SettingsDialog_UI_DataGridView_columnTooltipColumnizerMaskStale { + get { + return ResourceManager.GetString("SettingsDialog_UI_DataGridView_columnTooltipColumnizerMaskStale", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Glob uses * and ? wildcards. Regex uses .NET regular expression syntax.. + /// + public static string SettingsDialog_UI_DataGridView_columnTooltipColumnizerMaskType { + get { + return ResourceManager.GetString("SettingsDialog_UI_DataGridView_columnTooltipColumnizerMaskType", resourceCulture); + } + } + /// /// Looks up a localized string similar to Columnizer. /// @@ -5626,6 +5653,15 @@ public static string SettingsDialog_UI_FolderBrowser_folderBrowserWorkingDir { } } + /// + /// Looks up a localized string similar to Columnizer selection priority. + /// + public static string SettingsDialog_UI_GroupBox_groupBoxColumnizerPriority { + get { + return ResourceManager.GetString("SettingsDialog_UI_GroupBox_groupBoxColumnizerPriority", resourceCulture); + } + } + /// /// Looks up a localized string similar to Appearance. /// @@ -6221,6 +6257,33 @@ public static string SettingsDialog_UI_RadioButton_radioButtonVerticalMouseDragI } } + /// + /// Looks up a localized string similar to Use history then mask (default). + /// + public static string SettingsDialog_UI_RadioButton_radioColumnizerPriorityHistoryThenMask { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioColumnizerPriorityHistoryThenMask", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use mask, override per-file persistence. + /// + public static string SettingsDialog_UI_RadioButton_radioColumnizerPriorityMaskOverridesPersistence { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioColumnizerPriorityMaskOverridesPersistence", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use mask, then history. + /// + public static string SettingsDialog_UI_RadioButton_radioColumnizerPriorityMaskThenHistory { + get { + return ResourceManager.GetString("SettingsDialog_UI_RadioButton_radioColumnizerPriorityMaskThenHistory", resourceCulture); + } + } + /// /// Looks up a localized string similar to This path is based on the executable and where it has been started from.. /// diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index ee5302f3..02c7ca72 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -885,6 +885,27 @@ Checked tools will appear in the icon bar. All other tools are available in the Mask has priority before history + + Columnizer selection priority + + + Use history then mask (default) + + + Use mask, then history + + + Use mask, override per-file persistence + + + Type + + + Glob uses * and ? wildcards. Regex uses .NET regular expression syntax. + + + Columnizer '{0}' is not installed — this entry will be skipped. + Activate Portable Mode diff --git a/src/LogExpert.Tests/ColumnizerTests/ColumnizerMaskMatcherTests.cs b/src/LogExpert.Tests/ColumnizerTests/ColumnizerMaskMatcherTests.cs new file mode 100644 index 00000000..c78b30d6 --- /dev/null +++ b/src/LogExpert.Tests/ColumnizerTests/ColumnizerMaskMatcherTests.cs @@ -0,0 +1,106 @@ +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Config; + +using NUnit.Framework; + +namespace LogExpert.Tests.ColumnizerTests; + +[TestFixture] +public class ColumnizerMaskMatcherTests +{ + private static ColumnizerMaskEntry Entry (string mask, MaskType type) => new() { Mask = mask, Type = type, ColumnizerName = "X" }; + + // Glob tests + + [Test] + public void GlobStarLog_MatchesFooLog () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("*.log", MaskType.Glob), "foo.log"), Is.True); + } + + [Test] + public void GlobStarLog_DoesNotMatchFooTxt () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("*.log", MaskType.Glob), "foo.txt"), Is.False); + } + + [Test] + public void GlobQuestion_MatchesSingleChar () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("?.log", MaskType.Glob), "a.log"), Is.True); + } + + [Test] + public void GlobQuestion_DoesNotMatchTwoChars () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("?.log", MaskType.Glob), "aa.log"), Is.False); + } + + [Test] + public void Glob_IsCaseInsensitive () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("*.LOG", MaskType.Glob), "foo.log"), Is.True); + } + + [Test] + public void Glob_EscapesRegexMetacharacters () + { + // "+" must be treated literally in glob mode + Assert.That(ColumnizerMaskMatcher.Matches(Entry("foo+bar.log", MaskType.Glob), "foo+bar.log"), Is.True); + Assert.That(ColumnizerMaskMatcher.Matches(Entry("foo+bar.log", MaskType.Glob), "fooXbar.log"), Is.False); + } + + [Test] + public void Glob_DotIsLiteral () + { + // "my.log" glob: dot must match literal dot, not any char + Assert.That(ColumnizerMaskMatcher.Matches(Entry("my.log", MaskType.Glob), "my.log"), Is.True); + Assert.That(ColumnizerMaskMatcher.Matches(Entry("my.log", MaskType.Glob), "myXlog"), Is.False); + } + + // Regex tests + + [Test] + public void Regex_MatchesAnchoredPattern () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry(@".+\.log$", MaskType.Regex), "foo.log"), Is.True); + } + + [Test] + public void Regex_DoesNotMatchUnrelated () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry(@".+\.log$", MaskType.Regex), "foo.txt"), Is.False); + } + + [Test] + public void Regex_MalformedPatternReturnsFalse () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("[", MaskType.Regex), "foo.log"), Is.False); + } + + // Null / empty input + + [Test] + public void NullMask_ReturnsFalse () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry(null!, MaskType.Glob), "foo.log"), Is.False); + } + + [Test] + public void EmptyMask_ReturnsFalse () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry(string.Empty, MaskType.Glob), "foo.log"), Is.False); + } + + [Test] + public void NullFileName_ReturnsFalse () + { + Assert.That(ColumnizerMaskMatcher.Matches(Entry("*.log", MaskType.Glob), null!), Is.False); + } + + [Test] + public void NullEntry_ReturnsFalse () + { + Assert.That(ColumnizerMaskMatcher.Matches(null!, "foo.log"), Is.False); + } +} diff --git a/src/LogExpert.Tests/ColumnizerTests/ColumnizerResolverTests.cs b/src/LogExpert.Tests/ColumnizerTests/ColumnizerResolverTests.cs new file mode 100644 index 00000000..1ed7cb5e --- /dev/null +++ b/src/LogExpert.Tests/ColumnizerTests/ColumnizerResolverTests.cs @@ -0,0 +1,255 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Columnizer; +using LogExpert.Core.Config; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.ColumnizerTests; + +[TestFixture] +public class ColumnizerResolverTests +{ + private static ILogLineMemoryColumnizer MakeColumnizer (string name) + { + var mock = new Mock(); + _ = mock.Setup(c => c.GetName()).Returns(name); + return mock.Object; + } + + private static ColumnizerMaskEntry Mask (string glob, string columnizerName) => new() { Mask = glob, Type = MaskType.Glob, ColumnizerName = columnizerName }; + + #region HistoryThenMask (default) + + [Test] + public void HistoryThenMask_HistoryPresent_HistoryWins () + { + var history = MakeColumnizer("HistC"); + var mask = MakeColumnizer("MaskC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.HistoryThenMask, + FileName = "x.log", + ShortFileName = "x.log", + MaskList = [Mask("*.log", "MaskC")], + HistoryLookup = _ => "HistC", + Registered = [history, mask], + }); + + Assert.That(winner, Is.SameAs(history)); + } + + [Test] + public void HistoryThenMask_HistoryAbsent_MaskWins () + { + var mask = MakeColumnizer("MaskC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.HistoryThenMask, + FileName = "x.log", + ShortFileName = "x.log", + MaskList = [Mask("*.log", "MaskC")], + HistoryLookup = _ => null, + Registered = [mask], + }); + + Assert.That(winner, Is.SameAs(mask)); + } + + [Test] + public void HistoryThenMask_BothAbsent_AutoPickFires () + { + var auto = MakeColumnizer("AutoC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.HistoryThenMask, + FileName = "x.log", + ShortFileName = "x.log", + AutoPick = () => auto, + Registered = [auto], + }); + + Assert.That(winner, Is.SameAs(auto)); + } + + [Test] + public void HistoryThenMask_AllSourcesNull_ReturnsNull () + { + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.HistoryThenMask, + FileName = "x.log", + ShortFileName = "x.log", + }); + + Assert.That(winner, Is.Null); + } + + [Test] + public void HistoryThenMask_PersistencePresent_BeatsHistoryAndMask () + { + var pers = MakeColumnizer("PersC"); + var hist = MakeColumnizer("HistC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.HistoryThenMask, + FileName = "x.log", + ShortFileName = "x.log", + PersistenceColumnizerName = "PersC", + HistoryLookup = _ => "HistC", + Registered = [pers, hist], + }); + + Assert.That(winner, Is.SameAs(pers)); + } + + #endregion + + #region MaskThenHistory + + [Test] + public void MaskThenHistory_MaskPresent_MaskBeatsHistory () + { + var mask = MakeColumnizer("MaskC"); + var hist = MakeColumnizer("HistC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.MaskThenHistory, + FileName = "x.log", + ShortFileName = "x.log", + MaskList = [Mask("*.log", "MaskC")], + HistoryLookup = _ => "HistC", + Registered = [mask, hist], + }); + + Assert.That(winner, Is.SameAs(mask)); + } + + [Test] + public void MaskThenHistory_PersistenceStillBeatsMask () + { + var pers = MakeColumnizer("PersC"); + var mask = MakeColumnizer("MaskC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.MaskThenHistory, + FileName = "x.log", + ShortFileName = "x.log", + MaskList = [Mask("*.log", "MaskC")], + PersistenceColumnizerName = "PersC", + Registered = [pers, mask], + }); + + Assert.That(winner, Is.SameAs(pers)); + } + + #endregion + + #region MaskOverridesPersistence + + [Test] + public void MaskOverridesPersistence_MaskPresent_BeatsPersistence () + { + var pers = MakeColumnizer("PersC"); + var mask = MakeColumnizer("MaskC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.MaskOverridesPersistence, + FileName = "x.log", + ShortFileName = "x.log", + MaskList = [Mask("*.log", "MaskC")], + PersistenceColumnizerName = "PersC", + Registered = [pers, mask], + }); + + Assert.That(winner, Is.SameAs(mask)); + } + + [Test] + public void MaskOverridesPersistence_MaskAbsent_PersistenceWins () + { + var pers = MakeColumnizer("PersC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.MaskOverridesPersistence, + FileName = "x.log", + ShortFileName = "x.log", + MaskList = [Mask("*.txt", "Other")], // does not match + PersistenceColumnizerName = "PersC", + Registered = [pers], + }); + + Assert.That(winner, Is.SameAs(pers)); + } + #endregion + + #region Stale handling + + [Test] + public void StaleEntryFirst_ValidEntryWins_OnStaleInvokedOnce () + { + var validC = MakeColumnizer("Valid"); + var stale = Mask("*.log", "MissingPlugin"); + var valid = Mask("*.log", "Valid"); + var staleCallbacks = new List(); + + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.MaskThenHistory, + FileName = "foo.log", + ShortFileName = "foo.log", + MaskList = [stale, valid], + Registered = [validC], + OnStaleMaskEntry = e => staleCallbacks.Add(e), + }); + + Assert.That(winner, Is.SameAs(validC)); + Assert.That(staleCallbacks, Has.Count.EqualTo(1)); + Assert.That(staleCallbacks[0], Is.SameAs(stale)); + } + + [Test] + public void AllStale_MaskReturnsNull_FallsThroughToAutoPick () + { + var auto = MakeColumnizer("AutoC"); + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.MaskThenHistory, + FileName = "foo.log", + ShortFileName = "foo.log", + MaskList = [Mask("*.log", "Gone1"), Mask("*.log", "Gone2")], + AutoPick = () => auto, + Registered = [auto], + }); + + Assert.That(winner, Is.SameAs(auto)); + } + + [Test] + public void AutoPickDoesNotOverrideExplicitMatch () + { + var hist = MakeColumnizer("HistC"); + var auto = MakeColumnizer("AutoC"); + var autoFired = false; + var winner = ColumnizerResolver.Resolve(new ResolveInputs + { + Priority = ColumnizerSelectionPriority.HistoryThenMask, + FileName = "x.log", + ShortFileName = "x.log", + HistoryLookup = _ => "HistC", + AutoPick = () => + { + autoFired = true; + return auto; + }, + Registered = [hist, auto], + }); + + Assert.That(winner, Is.SameAs(hist)); + Assert.That(autoFired, Is.False); + } + + #endregion +} \ No newline at end of file diff --git a/src/LogExpert.Tests/ConfigManagerTests/LegacyPreferencesMigratorTests.cs b/src/LogExpert.Tests/ConfigManagerTests/LegacyPreferencesMigratorTests.cs new file mode 100644 index 00000000..80f5c7f0 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/LegacyPreferencesMigratorTests.cs @@ -0,0 +1,78 @@ +using LogExpert.Configuration; +using LogExpert.Core.Config; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +[TestFixture] +public class LegacyPreferencesMigratorTests +{ + [Test] + public void V0Settings_WithMaskPrioTrue_BecomesMaskThenHistory () + { + var settings = new Settings + { + SettingsVersion = 0, + }; +#pragma warning disable CS0618 + settings.Preferences.MaskPrio = true; +#pragma warning restore CS0618 + + var changed = LegacyPreferencesMigrator.Migrate(settings); + + Assert.That(changed, Is.True); + Assert.That(settings.Preferences.ColumnizerSelectionPriority, Is.EqualTo(ColumnizerSelectionPriority.MaskThenHistory)); + Assert.That(settings.SettingsVersion, Is.EqualTo(1)); + } + + [Test] + public void V0Settings_WithMaskPrioFalse_KeepsDefaultPriority () + { + var settings = new Settings { SettingsVersion = 0 }; + + _ = LegacyPreferencesMigrator.Migrate(settings); + + Assert.That(settings.Preferences.ColumnizerSelectionPriority, Is.EqualTo(ColumnizerSelectionPriority.HistoryThenMask)); + Assert.That(settings.SettingsVersion, Is.EqualTo(1)); + } + + [Test] + public void V0Settings_PreExistingMaskEntries_AreRewrittenToRegex () + { + var settings = new Settings { SettingsVersion = 0 }; + settings.Preferences.ColumnizerMaskList.Add(new ColumnizerMaskEntry + { + Mask = @".+\.log$", + ColumnizerName = "JsonColumnizer", + // Default-loaded value is Glob (the new default). + }); + + _ = LegacyPreferencesMigrator.Migrate(settings); + + Assert.That(settings.Preferences.ColumnizerMaskList[0].Type, Is.EqualTo(MaskType.Regex)); + } + + [Test] + public void CurrentSettings_IsNoOp () + { + var settings = new Settings { SettingsVersion = LegacyPreferencesMigrator.CURRENT_SETTINGS_VERSION }; + settings.Preferences.ColumnizerMaskList.Add(new ColumnizerMaskEntry + { + Mask = "*.log", + ColumnizerName = "C", + Type = MaskType.Glob, + }); + + var changed = LegacyPreferencesMigrator.Migrate(settings); + + Assert.That(changed, Is.False); + Assert.That(settings.Preferences.ColumnizerMaskList[0].Type, Is.EqualTo(MaskType.Glob)); + } + + [Test] + public void NullSettings_Throws () + { + _ = Assert.Throws(() => LegacyPreferencesMigrator.Migrate(null!)); + } +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index f6a22685..118aef06 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -5279,7 +5279,7 @@ internal void WritePipeTab (IList lineEntryList, string title) private static void FilterRestore (LogWindow newWin, PersistenceData persistenceData) { newWin.WaitForLoadingFinished(); - var columnizer = ColumnizerPicker.FindMemorColumnizerByName(persistenceData.Columnizer.GetName(), PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(persistenceData.Columnizer.GetName(), PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); if (columnizer != null) { @@ -6358,6 +6358,24 @@ public void LoadFile (string fileName, EncodingOptions encodingOptions) SetDefaultHighlightGroup(); } + else if (ConfigManager.Settings.Preferences.ColumnizerSelectionPriority == Core.Config.ColumnizerSelectionPriority.MaskOverridesPersistence + && !IsTempFile) + { + // The user opted in to mask-overrides-persistence: the .lxp file's bookmarks, filters, + // highlight group, encoding etc. still apply, but the columnizer is replaced by the + // mask list's match (if any). + var shortName = Util.GetNameFromPath(fileName); + var maskColumnizer = _logWindowCoordinator.TryGetMaskColumnizer(shortName); + if (maskColumnizer != null) + { + if (_reloadMemento == null) + { + maskColumnizer = ColumnizerPicker.CloneMemoryColumnizer(maskColumnizer, ConfigManager.ActiveConfigDir); + } + + PreSelectColumnizer(maskColumnizer); + } + } // this may be set after loading persistence data if (_fileNames != null && IsMultiFile) @@ -6640,7 +6658,7 @@ private void PreSelectColumnizer (ILogLineMemoryColumnizer columnizer) public void PreSelectColumnizerByName (string columnizerName) { - var columnizer = ColumnizerPicker.FindMemorColumnizerByName(columnizerName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(columnizerName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); PreSelectColumnizer(ColumnizerPicker.CloneMemoryColumnizer(columnizer, ConfigManager.ActiveConfigDir)); } diff --git a/src/LogExpert.UI/Dialogs/Helpers/EmptyImageCell.cs b/src/LogExpert.UI/Dialogs/Helpers/EmptyImageCell.cs new file mode 100644 index 00000000..e53795f9 --- /dev/null +++ b/src/LogExpert.UI/Dialogs/Helpers/EmptyImageCell.cs @@ -0,0 +1,12 @@ +namespace LogExpert.UI.Dialogs.Helpers; + +/// +/// Image cell whose new-row default is a blank bitmap instead of the framework's "image in error" +/// glyph, so the columnizer grid's new-row placeholder does not show a red X. +/// +public sealed class EmptyImageCell : DataGridViewImageCell +{ + private static readonly Image _blank = new Bitmap(16, 16); + + public override object DefaultNewRowValue => _blank; +} \ No newline at end of file diff --git a/src/LogExpert.UI/Dialogs/FileStatus.cs b/src/LogExpert.UI/Dialogs/Helpers/FileStatus.cs similarity index 93% rename from src/LogExpert.UI/Dialogs/FileStatus.cs rename to src/LogExpert.UI/Dialogs/Helpers/FileStatus.cs index cdf8f891..b40bcf03 100644 --- a/src/LogExpert.UI/Dialogs/FileStatus.cs +++ b/src/LogExpert.UI/Dialogs/Helpers/FileStatus.cs @@ -1,4 +1,4 @@ -namespace LogExpert.UI.Dialogs; +namespace LogExpert.UI.Dialogs.Helpers; /// /// Represents the status of a file in the missing files dialog. diff --git a/src/LogExpert.UI/Dialogs/MissingFileItem.cs b/src/LogExpert.UI/Dialogs/Helpers/MissingFileItem.cs similarity index 68% rename from src/LogExpert.UI/Dialogs/MissingFileItem.cs rename to src/LogExpert.UI/Dialogs/Helpers/MissingFileItem.cs index 73c35497..8827ae39 100644 --- a/src/LogExpert.UI/Dialogs/MissingFileItem.cs +++ b/src/LogExpert.UI/Dialogs/Helpers/MissingFileItem.cs @@ -1,19 +1,21 @@ -namespace LogExpert.UI.Dialogs; +namespace LogExpert.UI.Dialogs.Helpers; /// /// Represents a file item in the Missing Files Dialog ListView. /// -public class MissingFileItem +/// Original path from session file +/// Current file status +public class MissingFileItem (string originalPath, FileStatus status) { /// /// Original file path from the session/project file. /// - public string OriginalPath { get; set; } + public string OriginalPath { get; set; } = originalPath; /// /// Current status of the file. /// - public FileStatus Status { get; set; } + public FileStatus Status { get; set; } = status; /// /// List of alternative paths that might be the same file. @@ -23,7 +25,7 @@ public class MissingFileItem /// /// Currently selected path (original or alternative). /// - public string SelectedPath { get; set; } + public string SelectedPath { get; set; } = originalPath; /// /// Indicates whether the file is accessible. @@ -46,16 +48,4 @@ public class MissingFileItem FileStatus.AlternativeSelected => "Alternative Selected", _ => "Unknown" }; - - /// - /// Constructor for MissingFileItem. - /// - /// Original path from session file - /// Current file status - public MissingFileItem (string originalPath, FileStatus status) - { - OriginalPath = originalPath; - Status = status; - SelectedPath = originalPath; - } } diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs b/src/LogExpert.UI/Dialogs/Helpers/MissingFilesDialogResult.cs similarity index 97% rename from src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs rename to src/LogExpert.UI/Dialogs/Helpers/MissingFilesDialogResult.cs index d0ada4ec..6117adcd 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs +++ b/src/LogExpert.UI/Dialogs/Helpers/MissingFilesDialogResult.cs @@ -1,4 +1,4 @@ -namespace LogExpert.UI.Dialogs; +namespace LogExpert.UI.Dialogs.Helpers; /// /// Represents the result of the Missing Files Dialog interaction. diff --git a/src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs b/src/LogExpert.UI/Dialogs/Helpers/MissingFilesMessageBox.cs similarity index 98% rename from src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs rename to src/LogExpert.UI/Dialogs/Helpers/MissingFilesMessageBox.cs index da39de1f..1ef0b8ad 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs +++ b/src/LogExpert.UI/Dialogs/Helpers/MissingFilesMessageBox.cs @@ -3,7 +3,7 @@ using LogExpert.Core.Classes.Persister; -namespace LogExpert.UI.Dialogs; +namespace LogExpert.UI.Dialogs.Helpers; /// /// Temporary helper for showing missing file alerts until full dialog is implemented. diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 0591e4d0..9caadd91 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -17,6 +17,7 @@ using LogExpert.Core.Interfaces; using LogExpert.Dialogs; using LogExpert.UI.Dialogs; +using LogExpert.UI.Dialogs.Helpers; using LogExpert.UI.Entities; using LogExpert.UI.Extensions; using LogExpert.UI.Extensions.LogWindow; diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs index 04d8b34d..74e85f4e 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs @@ -2,6 +2,7 @@ using System.Runtime.Versioning; using LogExpert.Core.Classes.Persister; +using LogExpert.UI.Dialogs.Helpers; namespace LogExpert.UI.Dialogs; diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs index e1027136..cfd9aa09 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.Designer.cs @@ -7,24 +7,10 @@ partial class SettingsDialog /// 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 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 () { @@ -101,12 +87,13 @@ private void InitializeComponent () labelArguments = new Label(); textBoxArguments = new TextBox(); tabPageColumnizers = new TabPage(); + groupBoxColumnizerPriority = new GroupBox(); checkBoxAutoPick = new CheckBox(); - checkBoxMaskPrio = new CheckBox(); + radioColumnizerPriorityHistoryThenMask = new RadioButton(); + radioColumnizerPriorityMaskThenHistory = new RadioButton(); + radioColumnizerPriorityMaskOverridesPersistence = new RadioButton(); buttonDelete = new Button(); dataGridViewColumnizer = new DataGridView(); - dataGridViewTextBoxColumnFileMask = new DataGridViewTextBoxColumn(); - dataGridViewComboBoxColumnColumnizer = new DataGridViewComboBoxColumn(); tabPageHighlightMask = new TabPage(); dataGridViewHighlightMask = new DataGridView(); dataGridViewTextBoxColumnFileName = new DataGridViewTextBoxColumn(); @@ -188,6 +175,10 @@ private void InitializeComponent () toolTip = new ToolTip(components); buttonExport = new Button(); buttonImport = new Button(); + dataGridViewImageColumnColumnizerStale = new DataGridViewImageColumn(); + dataGridViewTextBoxColumnFileMask = new DataGridViewTextBoxColumn(); + dataGridViewComboBoxColumnColumnizerMaskType = new DataGridViewComboBoxColumn(); + dataGridViewComboBoxColumnColumnizer = new DataGridViewComboBoxColumn(); tabControlSettings.SuspendLayout(); tabPageViewSettings.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)upDownMaximumLineLength).BeginInit(); @@ -206,6 +197,7 @@ private void InitializeComponent () tabPageExternalTools.SuspendLayout(); groupBoxToolSettings.SuspendLayout(); tabPageColumnizers.SuspendLayout(); + groupBoxColumnizerPriority.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)dataGridViewColumnizer).BeginInit(); tabPageHighlightMask.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)dataGridViewHighlightMask).BeginInit(); @@ -1058,8 +1050,7 @@ private void InitializeComponent () // // tabPageColumnizers // - tabPageColumnizers.Controls.Add(checkBoxAutoPick); - tabPageColumnizers.Controls.Add(checkBoxMaskPrio); + tabPageColumnizers.Controls.Add(groupBoxColumnizerPriority); tabPageColumnizers.Controls.Add(buttonDelete); tabPageColumnizers.Controls.Add(dataGridViewColumnizer); tabPageColumnizers.Location = new Point(4, 24); @@ -1071,12 +1062,25 @@ private void InitializeComponent () tabPageColumnizers.Text = "Columnizers"; tabPageColumnizers.UseVisualStyleBackColor = true; // + // groupBoxColumnizerPriority + // + groupBoxColumnizerPriority.Controls.Add(checkBoxAutoPick); + groupBoxColumnizerPriority.Controls.Add(radioColumnizerPriorityHistoryThenMask); + groupBoxColumnizerPriority.Controls.Add(radioColumnizerPriorityMaskThenHistory); + groupBoxColumnizerPriority.Controls.Add(radioColumnizerPriorityMaskOverridesPersistence); + groupBoxColumnizerPriority.Location = new Point(4, 339); + groupBoxColumnizerPriority.Name = "groupBoxColumnizerPriority"; + groupBoxColumnizerPriority.Size = new Size(500, 98); + groupBoxColumnizerPriority.TabIndex = 4; + groupBoxColumnizerPriority.TabStop = false; + groupBoxColumnizerPriority.Text = "Columnizer selection priority"; + // // checkBoxAutoPick // checkBoxAutoPick.AutoSize = true; checkBoxAutoPick.Checked = true; checkBoxAutoPick.CheckState = CheckState.Checked; - checkBoxAutoPick.Location = new Point(530, 386); + checkBoxAutoPick.Location = new Point(301, 72); checkBoxAutoPick.Margin = new Padding(4, 5, 4, 5); checkBoxAutoPick.Name = "checkBoxAutoPick"; checkBoxAutoPick.Size = new Size(192, 19); @@ -1084,20 +1088,41 @@ private void InitializeComponent () checkBoxAutoPick.Text = "Automatically pick for new files"; checkBoxAutoPick.UseVisualStyleBackColor = true; // - // checkBoxMaskPrio - // - checkBoxMaskPrio.AutoSize = true; - checkBoxMaskPrio.Location = new Point(213, 388); - checkBoxMaskPrio.Margin = new Padding(4, 5, 4, 5); - checkBoxMaskPrio.Name = "checkBoxMaskPrio"; - checkBoxMaskPrio.Size = new Size(192, 19); - checkBoxMaskPrio.TabIndex = 4; - checkBoxMaskPrio.Text = "Mask has priority before history"; - checkBoxMaskPrio.UseVisualStyleBackColor = true; + // radioColumnizerPriorityHistoryThenMask + // + radioColumnizerPriorityHistoryThenMask.AutoSize = true; + radioColumnizerPriorityHistoryThenMask.Checked = true; + radioColumnizerPriorityHistoryThenMask.Location = new Point(6, 22); + radioColumnizerPriorityHistoryThenMask.Name = "radioColumnizerPriorityHistoryThenMask"; + radioColumnizerPriorityHistoryThenMask.Size = new Size(189, 19); + radioColumnizerPriorityHistoryThenMask.TabIndex = 0; + radioColumnizerPriorityHistoryThenMask.TabStop = true; + radioColumnizerPriorityHistoryThenMask.Text = "Use history then mask (default)"; + radioColumnizerPriorityHistoryThenMask.UseVisualStyleBackColor = true; + // + // radioColumnizerPriorityMaskThenHistory + // + radioColumnizerPriorityMaskThenHistory.AutoSize = true; + radioColumnizerPriorityMaskThenHistory.Location = new Point(6, 48); + radioColumnizerPriorityMaskThenHistory.Name = "radioColumnizerPriorityMaskThenHistory"; + radioColumnizerPriorityMaskThenHistory.Size = new Size(144, 19); + radioColumnizerPriorityMaskThenHistory.TabIndex = 1; + radioColumnizerPriorityMaskThenHistory.Text = "Use mask, then history"; + radioColumnizerPriorityMaskThenHistory.UseVisualStyleBackColor = true; + // + // radioColumnizerPriorityMaskOverridesPersistence + // + radioColumnizerPriorityMaskOverridesPersistence.AutoSize = true; + radioColumnizerPriorityMaskOverridesPersistence.Location = new Point(6, 73); + radioColumnizerPriorityMaskOverridesPersistence.Name = "radioColumnizerPriorityMaskOverridesPersistence"; + radioColumnizerPriorityMaskOverridesPersistence.Size = new Size(227, 19); + radioColumnizerPriorityMaskOverridesPersistence.TabIndex = 2; + radioColumnizerPriorityMaskOverridesPersistence.Text = "Use mask, override per-file persistence"; + radioColumnizerPriorityMaskOverridesPersistence.UseVisualStyleBackColor = true; // // buttonDelete // - buttonDelete.Location = new Point(12, 380); + buttonDelete.Location = new Point(812, 395); buttonDelete.Margin = new Padding(4, 5, 4, 5); buttonDelete.Name = "buttonDelete"; buttonDelete.Size = new Size(112, 35); @@ -1112,28 +1137,17 @@ private void InitializeComponent () dataGridViewColumnizer.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; dataGridViewColumnizer.BackgroundColor = SystemColors.ControlLight; dataGridViewColumnizer.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; - dataGridViewColumnizer.Columns.AddRange(new DataGridViewColumn[] { dataGridViewTextBoxColumnFileMask, dataGridViewComboBoxColumnColumnizer }); + dataGridViewColumnizer.Columns.AddRange(new DataGridViewColumn[] { dataGridViewImageColumnColumnizerStale, dataGridViewTextBoxColumnFileMask, dataGridViewComboBoxColumnColumnizerMaskType, dataGridViewComboBoxColumnColumnizer }); dataGridViewColumnizer.Dock = DockStyle.Top; dataGridViewColumnizer.EditMode = DataGridViewEditMode.EditOnEnter; dataGridViewColumnizer.Location = new Point(4, 5); dataGridViewColumnizer.Margin = new Padding(4, 5, 4, 5); dataGridViewColumnizer.Name = "dataGridViewColumnizer"; dataGridViewColumnizer.RowHeadersWidth = 62; - dataGridViewColumnizer.Size = new Size(934, 365); + dataGridViewColumnizer.Size = new Size(934, 326); dataGridViewColumnizer.TabIndex = 2; - dataGridViewColumnizer.RowsAdded += OnDataGridViewColumnizerRowsAdded; - // - // dataGridViewTextBoxColumnFileMask - // - dataGridViewTextBoxColumnFileMask.HeaderText = "File name mask (RegEx)"; - dataGridViewTextBoxColumnFileMask.MinimumWidth = 40; - dataGridViewTextBoxColumnFileMask.Name = "dataGridViewTextBoxColumnFileMask"; - // - // dataGridViewComboBoxColumnColumnizer - // - dataGridViewComboBoxColumnColumnizer.HeaderText = "Columnizer"; - dataGridViewComboBoxColumnColumnizer.MinimumWidth = 230; - dataGridViewComboBoxColumnColumnizer.Name = "dataGridViewComboBoxColumnColumnizer"; + dataGridViewColumnizer.DefaultValuesNeeded += OnDataGridViewColumnizerDefaultValuesNeeded; + dataGridViewColumnizer.CurrentCellDirtyStateChanged += OnDataGridViewColumnizerCurrentCellDirtyStateChanged; // // tabPageHighlightMask // @@ -1348,6 +1362,7 @@ private void InitializeComponent () columnControlCharEnabled.MinimumWidth = 30; columnControlCharEnabled.Name = "columnControlCharEnabled"; columnControlCharEnabled.Resizable = DataGridViewTriState.False; + columnControlCharEnabled.Width = 30; // // columnControlCharHex // @@ -2026,6 +2041,37 @@ private void InitializeComponent () buttonImport.UseVisualStyleBackColor = true; buttonImport.Click += OnBtnImportClick; // + // dataGridViewImageColumnColumnizerStale + // + dataGridViewImageColumnColumnizerStale.AutoSizeMode = DataGridViewAutoSizeColumnMode.None; + dataGridViewImageColumnColumnizerStale.HeaderText = ""; + dataGridViewImageColumnColumnizerStale.ImageLayout = DataGridViewImageCellLayout.Zoom; + dataGridViewImageColumnColumnizerStale.MinimumWidth = 24; + dataGridViewImageColumnColumnizerStale.Name = "dataGridViewImageColumnColumnizerStale"; + dataGridViewImageColumnColumnizerStale.ReadOnly = true; + dataGridViewImageColumnColumnizerStale.Resizable = DataGridViewTriState.False; + dataGridViewImageColumnColumnizerStale.Width = 24; + // + // dataGridViewTextBoxColumnFileMask + // + dataGridViewTextBoxColumnFileMask.HeaderText = "File name mask"; + dataGridViewTextBoxColumnFileMask.MinimumWidth = 40; + dataGridViewTextBoxColumnFileMask.Name = "dataGridViewTextBoxColumnFileMask"; + // + // dataGridViewComboBoxColumnColumnizerMaskType + // + dataGridViewComboBoxColumnColumnizerMaskType.AutoSizeMode = DataGridViewAutoSizeColumnMode.None; + dataGridViewComboBoxColumnColumnizerMaskType.HeaderText = "Type"; + dataGridViewComboBoxColumnColumnizerMaskType.MinimumWidth = 80; + dataGridViewComboBoxColumnColumnizerMaskType.Name = "dataGridViewComboBoxColumnColumnizerMaskType"; + dataGridViewComboBoxColumnColumnizerMaskType.Width = 80; + // + // dataGridViewComboBoxColumnColumnizer + // + dataGridViewComboBoxColumnColumnizer.HeaderText = "Columnizer"; + dataGridViewComboBoxColumnColumnizer.MinimumWidth = 230; + dataGridViewComboBoxColumnColumnizer.Name = "dataGridViewComboBoxColumnColumnizer"; + // // SettingsDialog // AcceptButton = buttonOk; @@ -2073,7 +2119,8 @@ private void InitializeComponent () groupBoxToolSettings.ResumeLayout(false); groupBoxToolSettings.PerformLayout(); tabPageColumnizers.ResumeLayout(false); - tabPageColumnizers.PerformLayout(); + groupBoxColumnizerPriority.ResumeLayout(false); + groupBoxColumnizerPriority.PerformLayout(); ((System.ComponentModel.ISupportInitialize)dataGridViewColumnizer).EndInit(); tabPageHighlightMask.ResumeLayout(false); ((System.ComponentModel.ISupportInitialize)dataGridViewHighlightMask).EndInit(); @@ -2141,10 +2188,11 @@ private void InitializeComponent () private System.Windows.Forms.TabPage tabPageColumnizers; private System.Windows.Forms.DataGridView dataGridViewColumnizer; private System.Windows.Forms.Button buttonDelete; - private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumnFileMask; - private System.Windows.Forms.DataGridViewComboBoxColumn dataGridViewComboBoxColumnColumnizer; private System.Windows.Forms.CheckBox checkBoxSysout; - private System.Windows.Forms.CheckBox checkBoxMaskPrio; + private System.Windows.Forms.GroupBox groupBoxColumnizerPriority; + private System.Windows.Forms.RadioButton radioColumnizerPriorityHistoryThenMask; + private System.Windows.Forms.RadioButton radioColumnizerPriorityMaskThenHistory; + private System.Windows.Forms.RadioButton radioColumnizerPriorityMaskOverridesPersistence; private System.Windows.Forms.GroupBox groupBoxMisc; private System.Windows.Forms.CheckBox checkBoxAskCloseTabs; private System.Windows.Forms.TabPage tabPageMultiFile; @@ -2271,4 +2319,8 @@ private void InitializeComponent () private DataGridViewTextBoxColumn columnControlCharAbbr; private DataGridViewTextBoxColumn columnControlCharCaret; private DataGridViewTextBoxColumn columnControlCharPreview; + private DataGridViewImageColumn dataGridViewImageColumnColumnizerStale; + private DataGridViewTextBoxColumn dataGridViewTextBoxColumnFileMask; + private DataGridViewComboBoxColumn dataGridViewComboBoxColumnColumnizerMaskType; + private DataGridViewComboBoxColumn dataGridViewComboBoxColumnColumnizer; } diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.cs index 09e5097a..5432af0f 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.cs @@ -6,7 +6,6 @@ using ColumnizerLib; -using LogExpert.Core.Classes.Columnizer; using LogExpert.Core.Config; using LogExpert.Core.Entities; using LogExpert.Core.Enums; @@ -14,6 +13,7 @@ using LogExpert.UI.ControlCharDisplay; using LogExpert.UI.Controls.LogTabWindow; using LogExpert.UI.Dialogs; +using LogExpert.UI.Dialogs.Helpers; using LogExpert.UI.Extensions; namespace LogExpert.Dialogs; @@ -26,6 +26,7 @@ internal partial class SettingsDialog : Form #region Fields private readonly Image _emptyImage = new Bitmap(16, 16); + private readonly Image _staleImage = SystemIcons.Exclamation.ToBitmap(); private readonly LogTabWindow _logTabWin; private const float DEFAULT_FONT_SIZE = 9.0f; @@ -74,6 +75,8 @@ private SettingsDialog (Preferences prefs, LogTabWindow logTabWin) InitializeComponent(); + dataGridViewImageColumnColumnizerStale.CellTemplate = new EmptyImageCell(); + LoadResources(); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -81,6 +84,23 @@ private SettingsDialog (Preferences prefs, LogTabWindow logTabWin) ResumeLayout(); } + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose (bool disposing) + { + if (disposing) + { + components?.Dispose(); + _staleImage?.Dispose(); + _emptyImage?.Dispose(); + } + + base.Dispose(disposing); + } + public SettingsDialog (Preferences prefs, LogTabWindow logTabWin, int tabToOpen, IConfigManager configManager) : this(prefs, logTabWin) { tabControlSettings.SelectedIndex = tabToOpen; @@ -139,6 +159,8 @@ private void ApplyTextResources () dataGridViewTextBoxColumnFileMask.HeaderText = Resources.SettingsDialog_UI_DataGridViewTextBoxColumn_FileMask; dataGridViewComboBoxColumnColumnizer.HeaderText = Resources.SettingsDialog_UI_DataGridViewComboBoxColumn_Columnizer; + dataGridViewComboBoxColumnColumnizerMaskType.HeaderText = Resources.SettingsDialog_UI_DataGridView_columnHeaderColumnizerMaskType; + dataGridViewComboBoxColumnColumnizerMaskType.ToolTipText = Resources.SettingsDialog_UI_DataGridView_columnTooltipColumnizerMaskType; dataGridViewTextBoxColumnFileName.HeaderText = Resources.SettingsDialog_UI_DataGridViewTextBoxColumn_FileName; dataGridViewComboBoxColumnHighlightGroup.HeaderText = Resources.SettingsDialog_UI_DataGridViewComboBoxColumn_HighlightGroup; } @@ -212,8 +234,6 @@ private void FillDialog () break; } case SessionSaveLocation.LoadedSessionFile: - // intentionally left blank - break; default: // intentionally left blank break; @@ -252,18 +272,30 @@ private void FillDialog () FillEncodingList(); FillLanguageList(); FillReaderTypeList(); + FillControlCharsTab(); comboBoxEncoding.SelectedItem = Encoding.GetEncoding(Preferences.DefaultEncoding); comboBoxLanguage.SelectedItem = CultureInfo.GetCultureInfo(Preferences.DefaultLanguage).Name; - checkBoxMaskPrio.Checked = Preferences.MaskPrio; + switch (Preferences.ColumnizerSelectionPriority) + { + case ColumnizerSelectionPriority.MaskThenHistory: + radioColumnizerPriorityMaskThenHistory.Checked = true; + break; + case ColumnizerSelectionPriority.MaskOverridesPersistence: + radioColumnizerPriorityMaskOverridesPersistence.Checked = true; + break; + case ColumnizerSelectionPriority.HistoryThenMask: + default: + radioColumnizerPriorityHistoryThenMask.Checked = true; + break; + } + checkBoxAutoPick.Checked = Preferences.AutoPick; checkBoxAskCloseTabs.Checked = Preferences.AskForClose; checkBoxColumnFinder.Checked = Preferences.ShowColumnFinder; checkBoxShowErrorMessageOnlyOneInstance.Checked = Preferences.ShowErrorMessageAllowOnlyOneInstances; - - FillControlCharsTab(); } private void FillReaderTypeList () @@ -313,65 +345,6 @@ private void SaveMultifileData () Preferences.MultiFileOptions.MaxDayTry = (int)upDownMultifileDays.Value; } - private static void OnBtnToolClickInternal (TextBox textBox) - { - OpenFileDialog dlg = new() - { - InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) - }; - - if (!string.IsNullOrEmpty(textBox.Text)) - { - FileInfo info = new(textBox.Text); - if (info.Directory != null && info.Directory.Exists) - { - dlg.InitialDirectory = info.DirectoryName; - } - } - - if (dlg.ShowDialog() == DialogResult.OK) - { - textBox.Text = dlg.FileName; - } - } - - //TODO: what is the purpose of this method? - private void OnBtnArgsClickInternal (TextBox textBox) - { - ToolArgsDialog dlg = new(_logTabWin, this) - { - Arg = textBox.Text - }; - - if (dlg.ShowDialog() == DialogResult.OK) - { - textBox.Text = dlg.Arg; - } - } - - private static void OnBtnWorkingDirClick (TextBox textBox) - { - FolderBrowserDialog dlg = new() - { - RootFolder = Environment.SpecialFolder.MyComputer, - Description = Resources.SettingsDialog_UI_FolderBrowser_folderBrowserWorkingDir - }; - - if (!string.IsNullOrEmpty(textBox.Text)) - { - DirectoryInfo info = new(textBox.Text); - if (info.Exists) - { - dlg.SelectedPath = info.FullName; - } - } - - if (dlg.ShowDialog() == DialogResult.OK) - { - textBox.Text = dlg.SelectedPath; - } - } - private void FillColumnizerForToolsList () { if (_selectedTool != null) @@ -395,9 +368,6 @@ private static void FillColumnizerForToolsList (ComboBox comboBox, string column } } - //ILogLineColumnizer columnizer = Util.FindColumnizerByName(columnizerName, this.logTabWin.RegisteredColumnizers); - //if (columnizer == null) - // columnizer = this.logTabWin.RegisteredColumnizers[0]; comboBox.SelectedIndex = selIndex; } @@ -405,46 +375,51 @@ private void FillColumnizerList () { dataGridViewColumnizer.Rows.Clear(); - var comboColumn = (DataGridViewComboBoxColumn)dataGridViewColumnizer.Columns[1]; + var comboColumn = (DataGridViewComboBoxColumn)dataGridViewColumnizer.Columns[3]; comboColumn.Items.Clear(); - //var textColumn = (DataGridViewTextBoxColumn)dataGridViewColumnizer.Columns[0]; + var typeColumn = (DataGridViewComboBoxColumn)dataGridViewColumnizer.Columns[2]; + typeColumn.ValueType = typeof(MaskType); + + if (typeColumn.Items.Count == 0) + { + _ = typeColumn.Items.Add(MaskType.Glob); + _ = typeColumn.Items.Add(MaskType.Regex); + } var columnizers = PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers; + var columnizerLookup = new HashSet(StringComparer.Ordinal); foreach (var columnizer in columnizers) { - _ = comboColumn.Items.Add(columnizer.GetName()); + var name = columnizer.GetName(); + _ = comboColumn.Items.Add(name); + _ = columnizerLookup.Add(name); } - //comboColumn.DisplayMember = "Name"; - //comboColumn.ValueMember = "Columnizer"; foreach (var maskEntry in Preferences.ColumnizerMaskList) { - DataGridViewRow row = new(); - _ = row.Cells.Add(new DataGridViewTextBoxCell()); - DataGridViewComboBoxCell cell = new(); + int rowIndex = dataGridViewColumnizer.Rows.Add(); + var row = dataGridViewColumnizer.Rows[rowIndex]; + + row.Cells[1].Value = maskEntry.Mask; + row.Cells[2].Value = maskEntry.Type; - foreach (var logColumnizer in columnizers) + if (columnizerLookup.Contains(maskEntry.ColumnizerName)) { - _ = cell.Items.Add(logColumnizer.GetName()); + row.Cells[3].Value = maskEntry.ColumnizerName; + row.Cells[0].Value = _emptyImage; + } + else + { + // Stale entry — the columnizer is not registered. Mark the row but keep the original name + // (a future re-install of the plugin will resurrect the entry). + row.Cells[3].Value = maskEntry.ColumnizerName; + row.Cells[0].Value = _staleImage; + row.Cells[0].ToolTipText = string.Format(CultureInfo.CurrentCulture, + Resources.SettingsDialog_UI_DataGridView_columnTooltipColumnizerMaskStale, + maskEntry.ColumnizerName); } - - _ = row.Cells.Add(cell); - row.Cells[0].Value = maskEntry.Mask; - var columnizer = ColumnizerPicker.DecideMemoryColumnizerByName(maskEntry.ColumnizerName, - PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); - - row.Cells[1].Value = columnizer.GetName(); - _ = dataGridViewColumnizer.Rows.Add(row); - } - - var count = dataGridViewColumnizer.RowCount; - - if (count > 0 && !dataGridViewColumnizer.Rows[count - 1].IsNewRow) - { - var comboCell = (DataGridViewComboBoxCell)dataGridViewColumnizer.Rows[count - 1].Cells[1]; - comboCell.Value = comboCell.Items[0]; } } @@ -477,9 +452,9 @@ private void FillHighlightMaskList () _ = row.Cells.Add(cell); row.Cells[0].Value = maskEntry.Mask; - var currentGroup = _logTabWin.FindHighlightGroup(maskEntry.HighlightGroupName); + //var currentGroup = _logTabWin.FindHighlightGroup(maskEntry.HighlightGroupName); var highlightGroupList = _logTabWin.HighlightGroupList; - currentGroup = highlightGroupList.Count > 0 ? highlightGroupList[0] : new HighlightGroup(); + var currentGroup = highlightGroupList.Count > 0 ? highlightGroupList[0] : new HighlightGroup(); row.Cells[1].Value = currentGroup.GroupName; _ = dataGridViewHighlightMask.Rows.Add(row); @@ -489,8 +464,7 @@ private void FillHighlightMaskList () if (count > 0 && !dataGridViewHighlightMask.Rows[count - 1].IsNewRow) { - var comboCell = - (DataGridViewComboBoxCell)dataGridViewHighlightMask.Rows[count - 1].Cells[1]; + var comboCell = (DataGridViewComboBoxCell)dataGridViewHighlightMask.Rows[count - 1].Cells[1]; comboCell.Value = comboCell.Items[0]; } } @@ -503,11 +477,17 @@ private void SaveColumnizerList () { if (!row.IsNewRow) { + var type = row.Cells[2].Value is MaskType maskType + ? maskType + : MaskType.Glob; + ColumnizerMaskEntry entry = new() { - Mask = (string)row.Cells[0].Value, - ColumnizerName = (string)row.Cells[1].Value + Mask = (string)row.Cells[1].Value, + Type = type, + ColumnizerName = (string)row.Cells[3].Value }; + Preferences.ColumnizerMaskList.Add(entry); } } @@ -526,6 +506,7 @@ private void SaveHighlightMaskList () Mask = (string)row.Cells[0].Value, HighlightGroupName = (string)row.Cells[1].Value }; + Preferences.HighlightMaskList.Add(entry); } } @@ -774,7 +755,11 @@ private void OnBtnOkClick (object sender, EventArgs e) SaveColumnizerList(); - Preferences.MaskPrio = checkBoxMaskPrio.Checked; + Preferences.ColumnizerSelectionPriority = radioColumnizerPriorityMaskOverridesPersistence.Checked + ? ColumnizerSelectionPriority.MaskOverridesPersistence + : radioColumnizerPriorityMaskThenHistory.Checked + ? ColumnizerSelectionPriority.MaskThenHistory + : ColumnizerSelectionPriority.HistoryThenMask; Preferences.AutoPick = checkBoxAutoPick.Checked; Preferences.AskForClose = checkBoxAskCloseTabs.Checked; Preferences.AllowOnlyOneInstance = checkBoxSingleInstance.Checked; @@ -823,22 +808,54 @@ private void OnBtnOkClick (object sender, EventArgs e) private void OnBtnToolClick (object sender, EventArgs e) { - OnBtnToolClickInternal(textBoxTool); + using OpenFileDialog dlg = new() + { + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + }; + + if (!string.IsNullOrEmpty(textBoxTool.Text)) + { + FileInfo info = new(textBoxTool.Text); + if (info.Directory != null && info.Directory.Exists) + { + dlg.InitialDirectory = info.DirectoryName; + } + } + + if (dlg.ShowDialog() == DialogResult.OK) + { + textBoxTool.Text = dlg.FileName; + } } - //TODO: what is the purpose of this click? private void OnBtnArgClick (object sender, EventArgs e) { - OnBtnArgsClickInternal(textBoxArguments); + using ToolArgsDialog dlg = new(_logTabWin, this) + { + Arg = textBoxArguments.Text + }; + + if (dlg.ShowDialog() == DialogResult.OK) + { + textBoxArguments.Text = dlg.Arg; + } } - //TODO Remove or refactor this function - private void OnDataGridViewColumnizerRowsAdded (object sender, DataGridViewRowsAddedEventArgs e) + /// + /// Adds default values to the Columnizer Grid when a new row is created. The default mask type is set to "Glob", + /// and if there are any registered columnizers, the first one in the list is selected as the default columnizer for + /// the new row. + /// + /// + /// + private void OnDataGridViewColumnizerDefaultValuesNeeded (object sender, DataGridViewRowEventArgs e) { - var comboCell = (DataGridViewComboBoxCell)dataGridViewColumnizer.Rows[e.RowIndex].Cells[1]; - if (comboCell.Items.Count > 0) + e.Row.Cells[2].Value = MaskType.Glob; + + var columnizers = PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers; + if (columnizers.Count > 0) { - //comboCell.Value = comboCell.Items[0]; + e.Row.Cells[3].Value = columnizers[0].GetName(); } } @@ -857,6 +874,14 @@ private void OnDataGridViewColumnizerDataError (object sender, DataGridViewDataE e.Cancel = true; } + private void OnDataGridViewColumnizerCurrentCellDirtyStateChanged (object sender, EventArgs e) + { + if (dataGridViewColumnizer.IsCurrentCellDirty) + { + _ = dataGridViewColumnizer.CommitEdit(DataGridViewDataErrorContexts.Commit); + } + } + private void OnChkBoxSysoutCheckedChanged (object sender, EventArgs e) { comboBoxColumnizer.Enabled = checkBoxSysout.Checked; @@ -1170,7 +1195,25 @@ private void OnBtnCancelClick (object sender, EventArgs e) private void OnBtnWorkingDirClick (object sender, EventArgs e) { - OnBtnWorkingDirClick(textBoxWorkingDir); + using FolderBrowserDialog dlg = new() + { + RootFolder = Environment.SpecialFolder.MyComputer, + Description = Resources.SettingsDialog_UI_FolderBrowser_folderBrowserWorkingDir + }; + + if (!string.IsNullOrEmpty(textBoxWorkingDir.Text)) + { + DirectoryInfo info = new(textBoxWorkingDir.Text); + if (info.Exists) + { + dlg.SelectedPath = info.FullName; + } + } + + if (dlg.ShowDialog() == DialogResult.OK) + { + textBoxWorkingDir.Text = dlg.SelectedPath; + } } [SupportedOSPlatform("windows")] @@ -1323,6 +1366,10 @@ private Dictionary GetToolTipMap () }; } + #endregion + + #region Control Chars Tab + private void FillControlCharsTab () { var s = Preferences.ControlCharSettings ??= new ControlCharSettings(); @@ -1336,11 +1383,32 @@ private void FillControlCharsTab () switch (s.Style) { - case ControlCharStyle.Caret: radioButtonControlCharStyleCaret.Checked = true; break; - case ControlCharStyle.CEscape: radioButtonControlCharStyleCEscape.Checked = true; break; - case ControlCharStyle.Abbreviation: radioButtonControlCharStyleAbbreviation.Checked = true; break; - case ControlCharStyle.Iso2047: radioButtonControlCharStyleIso2047.Checked = true; break; - default: radioButtonControlCharStyleControlPictures.Checked = true; break; + case ControlCharStyle.Caret: + { + radioButtonControlCharStyleCaret.Checked = true; + break; + } + case ControlCharStyle.CEscape: + { + radioButtonControlCharStyleCEscape.Checked = true; + break; + } + case ControlCharStyle.Abbreviation: + { + radioButtonControlCharStyleAbbreviation.Checked = true; + break; + } + case ControlCharStyle.Iso2047: + { + radioButtonControlCharStyleIso2047.Checked = true; + break; + } + case ControlCharStyle.ControlPictures: + default: + { + radioButtonControlCharStyleControlPictures.Checked = true; + break; + } } _controlCharsEnabledByCp.Clear(); diff --git a/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs b/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs index 74390967..2e380126 100644 --- a/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs +++ b/src/LogExpert.UI/Interface/ILogWindowCoordinator.cs @@ -31,12 +31,19 @@ internal interface ILogWindowCoordinator /// /// Resolves the appropriate columnizer for the given file name. - /// Respects MaskPrio preference (mask-first vs history-first). - /// Cleans up stale history entries. - /// Returns null when no match found. + /// Honours the configured . + /// Cleans up stale history entries. Returns null when no source produced a match. /// ILogLineMemoryColumnizer? ResolveColumnizer (string fileName); + /// + /// Returns the mask-list columnizer that matches , ignoring history, + /// persistence, and AutoPick. Used by the per-file load path when the user has opted in to + /// MaskOverridesPersistence. Returns null when no mask matches or the matching entry's + /// columnizer is not registered. + /// + ILogLineMemoryColumnizer? TryGetMaskColumnizer (string shortFileName); + /// /// Shared search parameters across all tabs. /// All tabs read/write from the same instance. diff --git a/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs b/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs index 76f95e16..c4a15d91 100644 --- a/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs +++ b/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs @@ -120,31 +120,33 @@ public HighlightGroup ResolveHighlightGroup (string? groupName, string? fileName var preferences = _configManager.Settings.Preferences; var shortName = Util.GetNameFromPath(fileName); - return preferences.MaskPrio - ? FindColumnizerByFileMask(shortName) ?? GetColumnizerHistoryEntry(fileName) - : GetColumnizerHistoryEntry(fileName) ?? FindColumnizerByFileMask(shortName); - } + // History lookup also cleans up stale entries — preserve that side-effect by routing through + // GetColumnizerHistoryEntry rather than a pure name lookup inside ColumnizerResolver. + var historyHit = GetColumnizerHistoryEntry(fileName); - private ILogLineMemoryColumnizer? FindColumnizerByFileMask (string fileName) - { - foreach (var entry in _configManager.Settings.Preferences.ColumnizerMaskList.Where(entry => entry.Mask != null)) + var inputs = new ResolveInputs { - try - { - if (Regex.IsMatch(fileName, entry.Mask)) - { - return ColumnizerPicker.FindMemorColumnizerByName( - entry.ColumnizerName, - _pluginRegistry.RegisteredColumnizers); - } - } - catch (ArgumentException e) - { - _logger.Error($"RegEx-error while finding columnizer: {e}"); - } - } + Priority = preferences.ColumnizerSelectionPriority, + FileName = fileName, + ShortFileName = shortName, + MaskList = preferences.ColumnizerMaskList, + HistoryLookup = _ => historyHit?.GetName(), + Registered = _pluginRegistry.RegisteredColumnizers, + OnStaleMaskEntry = entry => _logger.Warn( + $"Columnizer mask '{entry.Mask}' matched '{shortName}' but its columnizer '{entry.ColumnizerName}' is not registered — skipping."), + }; + + return ColumnizerResolver.Resolve(inputs); + } - return null; + public ILogLineMemoryColumnizer? TryGetMaskColumnizer (string shortFileName) + { + return ColumnizerResolver.TryGetMaskColumnizer( + _configManager.Settings.Preferences.ColumnizerMaskList, + shortFileName, + _pluginRegistry.RegisteredColumnizers, + entry => _logger.Warn( + $"Columnizer mask '{entry.Mask}' matched '{shortFileName}' but its columnizer '{entry.ColumnizerName}' is not registered — skipping.")); } private ILogLineMemoryColumnizer? GetColumnizerHistoryEntry (string fileName) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index e4150631..e09a2549 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,36 +10,35 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-05-22 18:40:00 UTC + /// Generated: 2026-05-28 08:22:43 UTC /// Configuration: Release - /// Plugin count: 22 + /// Plugin count: 21 /// public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "1C3A220CDF6CBDF89A8C4355EB258071AD1DDC9D8AF17561BE5681624078FB38", + ["AutoColumnizer.dll"] = "6E377F0AE721301F525FCEA7CD9B7FA30DB329BDB17E96F334EE6FBF1251502B", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "C92D60E9989E66E0BE4172A0061DEFA1FAE0CF29A223A30A05293079906890FC", - ["CsvColumnizer.dll (x86)"] = "C92D60E9989E66E0BE4172A0061DEFA1FAE0CF29A223A30A05293079906890FC", - ["DefaultPlugins.dll"] = "B5EE5C99499980A5C7C7D1ED23A8FA65E5DCDE6E9261D4D6C0E58CDD05ABE326", - ["FlashIconHighlighter.dll"] = "3F3224E917015205CDB28F5598CEE059E6E4A94D7D49F05613318ACE6B52FEF8", - ["GlassfishColumnizer.dll"] = "014926AD769F27DF892535F09205A003B864A410E98767A4F6F58CFD3DC1CB75", - ["JsonColumnizer.dll"] = "0EB6881F88C248D0786BAAAC4951AD7998E415790DF1118F3E02715476230A47", - ["JsonCompactColumnizer.dll"] = "A5430CBBACE8A4BB2A706F06DB0B0576D9C6EEDC51732D93CA2F4C2EF02D8836", - ["Log4jXmlColumnizer.dll"] = "BCA0C165D3D43FFCEA73DC1D01B0166D27C58416A04AF860846CB4059DAF299C", - ["LogExpert.Core.dll"] = "9E33740726A486E43D262EA07EAD4327D0A8C4C0C09FE7BFE1106AA18358CA7D", - ["LogExpert.Resources.dll"] = "10570A3ABE83A0997AD457333D36359EADE0296A57054BC931338952E3190656", + ["CsvColumnizer.dll"] = "CE335CCA69ACAA72F71B7AD63BF09CAAC3C9492A807B4DDD8053C062AB6433EA", + ["CsvColumnizer.dll (x86)"] = "CE335CCA69ACAA72F71B7AD63BF09CAAC3C9492A807B4DDD8053C062AB6433EA", + ["DefaultPlugins.dll"] = "9F0DDA2C2064BA5FBD0A68DF31EA29A7D30C2462A5742F27908C093CEF2AA7F9", + ["FlashIconHighlighter.dll"] = "83D60C9E885639193A9F609EB94BB735709D95B40A0CB904998AACA51D0D3016", + ["GlassfishColumnizer.dll"] = "2E8601C7A6BBCF2B76EC50850B9F03503FBF336F8332FF3365757B298FFEC94D", + ["JsonColumnizer.dll"] = "6596ED4BEAB67DABD89E8C4D8AE028A67B029594B706CAD0F652BF774EAD212A", + ["JsonCompactColumnizer.dll"] = "12553DC6DE8AE0F688707473608D2D474554C3EBB0FAE96E76A7D37FDEB3CEAE", + ["Log4jXmlColumnizer.dll"] = "B7AB67C37465E50C7FA6F640C7C410664F087C0411D5217B88BEEF91766A45D2", + ["LogExpert.Resources.dll"] = "8D2E41CADDFB81E03E6F2B2D48059CFABF7DB69BE3E33D6242115E5040BF6356", ["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"] = "A66E6F9A81CB856564D6DBD28B435C2AB953E09935A7450D3885A05672FF64D1", - ["SftpFileSystem.dll"] = "6A5A95C3E05305EF4A5B32E33DA466D8B4B4E3275E35D2B33F3347B561E6D7FB", - ["SftpFileSystem.dll (x86)"] = "91C022E72324EAD1467C55C853D099B7B05D319174F916315B6C63FA6099D9FD", - ["SftpFileSystem.Resources.dll"] = "CEBA933FB478A400A46865855807D18C10D1E3855014D1D75C3987E28CF5B36D", - ["SftpFileSystem.Resources.dll (x86)"] = "CEBA933FB478A400A46865855807D18C10D1E3855014D1D75C3987E28CF5B36D", + ["RegexColumnizer.dll"] = "AF3A7237BA515877D9A0D0C6814608B653790892ACBFC844BC031DDF8FD91D25", + ["SftpFileSystem.dll"] = "0B3DA99546B5319BA2BE407BC8D7ED65B4AF9E9E133C33B6F6DC315E15E9B8FB", + ["SftpFileSystem.dll (x86)"] = "28B9378D8E6D1EFD6257B61B1281A2870A94091E942DFC50DD373A4E684C47D7", + ["SftpFileSystem.Resources.dll"] = "DFD0ED9BB470D3EBC739E36386D222D51A6772FFEB7AEE7305E4A65789B5A98B", + ["SftpFileSystem.Resources.dll (x86)"] = "DFD0ED9BB470D3EBC739E36386D222D51A6772FFEB7AEE7305E4A65789B5A98B", }; } diff --git a/src/PluginRegistry/PluginRegistry.cs b/src/PluginRegistry/PluginRegistry.cs index f007c384..3eb28c49 100644 --- a/src/PluginRegistry/PluginRegistry.cs +++ b/src/PluginRegistry/PluginRegistry.cs @@ -71,7 +71,6 @@ private PluginRegistry (string applicationConfigurationFolder, int pollingInterv _applicationConfigurationFolder = applicationConfigurationFolder; PollingInterval = pollingInterval; - // Initialize Priority 3 & 4 components _pluginLoader = new DefaultPluginLoader(); _eventBus = new PluginEventBus(); @@ -121,9 +120,6 @@ public static void RegisterAssemblyResolver () { AppDomain.CurrentDomain.AssemblyResolve -= ColumnizerResolveEventHandler; AppDomain.CurrentDomain.AssemblyResolve += ColumnizerResolveEventHandler; - - //// Wire up the converter's assembly loader to go through validated loading - //LogExpert.Core.Classes.JsonConverters.ColumnizerJsonConverter.AssemblyLoader = LoadAndValidateAssembly; } #endregion @@ -148,7 +144,9 @@ public IList RegisteredColumnizers foreach (var loader in _lazyColumnizers.ToList()) { var instance = loader.GetInstance(); - if (instance != null && !field.Contains(instance)) + // Data duplication by type, not by reference: GetInstance() returns a freshly constructed + // object, so Contains() (reference equality) would never catch a same-type duplicate. + if (instance != null && !field.Any(c => c.GetType() == instance.GetType())) { field.Add(instance); InitializePluginIfNeeded(instance, loader.Manifest, loader.DllPath); @@ -872,6 +870,15 @@ private void ProcessLoadedPlugin (object plugin, PluginManifest? manifest, strin return; } + // Skip if a columnizer of this type is already registered. RegisteredColumnizers holds one + // template instance per type, so a same-type entry is always a duplicate (e.g. a built-in + // columnizer rediscovered because its defining assembly was scanned). + if (RegisteredColumnizers.Any(c => c.GetType() == columnizer.GetType())) + { + _logger.Debug("Columnizer type {Type} already registered, skipping duplicate", columnizer.GetType().FullName); + return; + } + // Add to registered columnizers RegisteredColumnizers.Add(columnizer); @@ -890,8 +897,7 @@ private void ProcessLoadedPlugin (object plugin, PluginManifest? manifest, strin } } - // Existing IColumnizerConfigurator support - if (columnizer is IColumnizerConfigurator configurator) + if (columnizer is IColumnizerConfiguratorMemory configurator) { try { diff --git a/src/PluginRegistry/PluginValidator.cs b/src/PluginRegistry/PluginValidator.cs index 69ca558f..4d7b72d2 100644 --- a/src/PluginRegistry/PluginValidator.cs +++ b/src/PluginRegistry/PluginValidator.cs @@ -40,6 +40,11 @@ public static partial class PluginValidator // Known safe dependencies (not plugins themselves) private static readonly HashSet _knownDependencies = new(StringComparer.OrdinalIgnoreCase) { + // LogExpert's own assemblies are not plugins. LogExpert.Core defines the built-in + // columnizers; if it (or a copy) lands in the plugins folder it must not be scanned, + // or the built-in columnizers would be registered twice. + "LogExpert.Core.dll", + "LogExpert.Resources.dll", "ColumnizerLib.dll", "Newtonsoft.Json.dll", "CsvHelper.dll", diff --git a/src/RegexColumnizer/RegexColumnizer.csproj b/src/RegexColumnizer/RegexColumnizer.csproj index 87ce44ad..6797cf88 100644 --- a/src/RegexColumnizer/RegexColumnizer.csproj +++ b/src/RegexColumnizer/RegexColumnizer.csproj @@ -12,7 +12,13 @@ - + + + false +