From d2226c4f293fec7d71c72551ce31e4ed120ea73e Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 10 Dec 2025 16:51:15 +0100 Subject: [PATCH 01/10] initial commit --- src/LogExpert.Configuration/ConfigManager.cs | 3 +- .../Classes/Persister/ProjectData.cs | 5 + .../Classes/Persister/ProjectFileValidator.cs | 226 ++++++++++++++++++ .../Classes/Persister/ProjectPersister.cs | 11 +- .../Persister/ProjectValidationResult.cs | 10 + src/LogExpert.Tests/ConfigManagerTest.cs | 3 +- .../Dialogs/LogTabWindow/LogTabWindow.cs | 2 +- 7 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs create mode 100644 src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs diff --git a/src/LogExpert.Configuration/ConfigManager.cs b/src/LogExpert.Configuration/ConfigManager.cs index e1437a7b..be708d5c 100644 --- a/src/LogExpert.Configuration/ConfigManager.cs +++ b/src/LogExpert.Configuration/ConfigManager.cs @@ -807,7 +807,8 @@ IOException or NotSupportedException or PathTooLongException or UnauthorizedAccessException or - SecurityException) + SecurityException or + JsonSerializationException) { _logger.Error($"Error while deserializing config data: {e}"); newGroups = []; diff --git a/src/LogExpert.Core/Classes/Persister/ProjectData.cs b/src/LogExpert.Core/Classes/Persister/ProjectData.cs index 47f64a7d..5a5abd3d 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectData.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectData.cs @@ -15,5 +15,10 @@ public class ProjectData /// public string TabLayoutXml { get; set; } + /// + /// Gets or sets the full file path to the project file. + /// + public string ProjectFilePath { get; set; } + #endregion } \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs b/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs new file mode 100644 index 00000000..9c886ae9 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs @@ -0,0 +1,226 @@ +using LogExpert.Core.Interface; + +namespace LogExpert.Core.Classes.Persister; + +public static class ProjectFileValidator +{ + public static ProjectValidationResult ValidateProject (ProjectData projectData, IPluginRegistry pluginRegistry) + { + ArgumentNullException.ThrowIfNull(projectData); + ArgumentNullException.ThrowIfNull(pluginRegistry); + + var result = new ProjectValidationResult(); + + foreach (var fileName in projectData.FileNames) + { + var normalizedPath = NormalizeFilePath(fileName); + + if (File.Exists(normalizedPath)) + { + result.ValidFiles.Add(fileName); + } + else if (IsUri(fileName)) + { + // Check if URI-based file system plugin is available + var fs = pluginRegistry.FindFileSystemForUri(fileName); + if (fs != null) + { + result.ValidFiles.Add(fileName); + } + else + { + result.MissingFiles.Add(fileName); + } + } + else + { + result.MissingFiles.Add(fileName); + + // Try to find file with relative path + var alternativePaths = FindAlternativePaths(fileName, projectData.ProjectFilePath); + result.PossibleAlternatives[fileName] = alternativePaths; + } + } + + return result; + } + + private static string NormalizeFilePath (string fileName) + { + // Handle .lxp files (persistence files) + if (fileName.EndsWith(".lxp", StringComparison.OrdinalIgnoreCase)) + { + var persistenceData = Persister.Load(fileName); + return persistenceData?.FileName ?? fileName; + } + + return fileName; + } + + private static bool IsUri (string fileName) + { + // Check if the string is a valid URI with a scheme (protocol) + // URIs typically have the format: scheme://path or scheme:/path + // Examples: sftp://server/file.log, http://example.com/log.txt + + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + // Try to parse as URI + if (Uri.TryCreate(fileName, UriKind.Absolute, out var uri)) + { + // Check if it has a scheme other than file:// + // file:// URIs are local file paths and should be handled as regular files + return !string.IsNullOrEmpty(uri.Scheme) && + !uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static List FindAlternativePaths (string fileName, string projectFilePath) + { + var alternatives = new List(); + + if (string.IsNullOrWhiteSpace(fileName)) + { + return alternatives; + } + + var baseName = Path.GetFileName(fileName); + + if (string.IsNullOrWhiteSpace(baseName)) + { + return alternatives; + } + + // Search in directory of .lxj project file + if (!string.IsNullOrWhiteSpace(projectFilePath)) + { + try + { + var projectDir = Path.GetDirectoryName(projectFilePath); + if (!string.IsNullOrEmpty(projectDir) && Directory.Exists(projectDir)) + { + var candidatePath = Path.Combine(projectDir, baseName); + if (File.Exists(candidatePath)) + { + alternatives.Add(candidatePath); + } + + // Also check subdirectories (one level deep) + var subdirs = Directory.GetDirectories(projectDir); + foreach (var subdir in subdirs) + { + var subdirCandidate = Path.Combine(subdir, baseName); + if (File.Exists(subdirCandidate)) + { + alternatives.Add(subdirCandidate); + } + } + } + } + catch (Exception ex) when (ex is ArgumentException or + ArgumentNullException or + PathTooLongException or + UnauthorizedAccessException or + IOException) + { + // Ignore errors when searching in project directory + } + } + + // Search in Documents/LogExpert folder + try + { + var documentsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "LogExpert"); + + if (Directory.Exists(documentsPath)) + { + var docCandidate = Path.Combine(documentsPath, baseName); + if (File.Exists(docCandidate) && !alternatives.Contains(docCandidate)) + { + alternatives.Add(docCandidate); + } + } + } + catch (Exception ex) when (ex is ArgumentException or + ArgumentNullException or + PathTooLongException or + UnauthorizedAccessException or + IOException) + { + // Ignore errors when searching in Documents folder + } + + // If the original path is absolute, try to find the file in the same directory structure + // but on a different drive (useful when drive letters change) + if (Path.IsPathRooted(fileName)) + { + try + { + var driveLetters = DriveInfo.GetDrives() + .Where(d => d.IsReady && d.DriveType == DriveType.Fixed) + .Select(d => d.Name[0]) + .ToList(); + + var originalDrive = Path.GetPathRoot(fileName)?[0]; + var pathWithoutDrive = fileName.Length > 3 ? fileName[3..] : string.Empty; + + foreach (var drive in driveLetters) + { + if (drive != originalDrive && !string.IsNullOrEmpty(pathWithoutDrive)) + { + var alternatePath = $"{drive}:\\{pathWithoutDrive}"; + if (File.Exists(alternatePath) && !alternatives.Contains(alternatePath)) + { + alternatives.Add(alternatePath); + } + } + } + } + catch (Exception ex) when (ex is ArgumentException or + ArgumentNullException or + PathTooLongException or + UnauthorizedAccessException or + IOException) + { + // Ignore errors when searching on different drives + } + } + + // Try relative path resolution from project directory + if (!Path.IsPathRooted(fileName) && !string.IsNullOrWhiteSpace(projectFilePath)) + { + try + { + var projectDir = Path.GetDirectoryName(projectFilePath); + if (!string.IsNullOrEmpty(projectDir)) + { + var relativePath = Path.Combine(projectDir, fileName); + var normalizedPath = Path.GetFullPath(relativePath); + + if (File.Exists(normalizedPath) && !alternatives.Contains(normalizedPath)) + { + alternatives.Add(normalizedPath); + } + } + } + catch (Exception ex) when (ex is ArgumentException or + ArgumentNullException or + PathTooLongException or + UnauthorizedAccessException or + IOException or + NotSupportedException) + { + // Ignore errors with relative path resolution + } + } + + return alternatives; + } +} diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs index b2ae8f17..94ee4abd 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs @@ -1,5 +1,7 @@ using System.Text; +using LogExpert.Core.Interface; + using Newtonsoft.Json; using NLog; @@ -17,7 +19,7 @@ public static class ProjectPersister /// /// /// - public static ProjectData LoadProjectData (string projectFileName) + public static ProjectData LoadProjectData (string projectFileName, IPluginRegistry pluginRegistry) { try { @@ -27,7 +29,12 @@ public static ProjectData LoadProjectData (string projectFileName) }; var json = File.ReadAllText(projectFileName, Encoding.UTF8); - return JsonConvert.DeserializeObject(json, settings); + var projectData = JsonConvert.DeserializeObject(json, settings); + + var hasLayout = projectData.TabLayoutXml != null; + var validationResult = ProjectFileValidator.ValidateProject(projectData, pluginRegistry); + + return projectData; } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or diff --git a/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs b/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs new file mode 100644 index 00000000..b98be6cf --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs @@ -0,0 +1,10 @@ +namespace LogExpert.Core.Classes.Persister; + +public class ProjectValidationResult +{ + public List ValidFiles { get; } = new(); + public List MissingFiles { get; } = new(); + public Dictionary> PossibleAlternatives { get; } = new(); + + public bool HasMissingFiles => MissingFiles.Count > 0; +} diff --git a/src/LogExpert.Tests/ConfigManagerTest.cs b/src/LogExpert.Tests/ConfigManagerTest.cs index 6b58183b..0c835a0f 100644 --- a/src/LogExpert.Tests/ConfigManagerTest.cs +++ b/src/LogExpert.Tests/ConfigManagerTest.cs @@ -675,8 +675,7 @@ public void Import_WithValidPopulatedSettings_ShouldSucceed () // Verify settings were actually imported Settings currentSettings = _configManager.Settings; - Assert.That(currentSettings.FilterList.Any(f => f.SearchText == "IMPORT_TEST_FILTER"), Is.True, - "Imported filter should be present"); + Assert.That(currentSettings.FilterList.Any(f => f.SearchText == "IMPORT_TEST_FILTER"), Is.True, "Imported filter should be present"); } [Test] diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 6d721e21..9add67ca 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -2015,7 +2015,7 @@ private static void SetTabColor (LogWindow.LogWindow logWindow, Color color) [SupportedOSPlatform("windows")] private void LoadProject (string projectFileName, bool restoreLayout) { - var projectData = ProjectPersister.LoadProjectData(projectFileName); + var projectData = ProjectPersister.LoadProjectData(projectFileName, PluginRegistry.PluginRegistry.Instance); var hasLayoutData = projectData.TabLayoutXml != null; if (hasLayoutData && restoreLayout && _logWindowList.Count > 0) From ad22e66caad447d4788512e18c1aa9c9db8f36f8 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 10 Dec 2025 16:51:53 +0100 Subject: [PATCH 02/10] update --- .../Classes/Persister/ProjectFileValidator.cs | 90 +++++++++++++------ 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs b/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs index 9c886ae9..742918a0 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs @@ -2,8 +2,27 @@ namespace LogExpert.Core.Classes.Persister; +/// +/// Provides static methods for validating project file references, identifying missing or accessible files, and +/// suggesting alternative file paths using available file system plugins. +/// +/// This class is intended for use with project data that includes file references and a project file +/// path. It supports validation of both local file paths and URI-based files through plugin resolution. All methods are +/// thread-safe and do not modify input data. Use the provided methods to check file existence, resolve canonical file +/// paths, and locate possible alternatives for missing files. public static class ProjectFileValidator { + /// + /// Validates the files referenced by the specified project and identifies missing or accessible files using + /// available file system plugins. + /// + /// Files are considered valid if they exist on disk or if a suitable file system plugin is + /// available for URI-based files. For missing files, possible alternative paths are suggested based on the project + /// file location. + /// The project data containing the list of file names to validate and the project file path. Cannot be null. + /// The plugin registry used to resolve file system plugins for URI-based files. Cannot be null. + /// A ProjectValidationResult containing lists of valid files, missing files, and possible alternative file paths + /// for missing files. public static ProjectValidationResult ValidateProject (ProjectData projectData, IPluginRegistry pluginRegistry) { ArgumentNullException.ThrowIfNull(projectData); @@ -36,7 +55,6 @@ public static ProjectValidationResult ValidateProject (ProjectData projectData, { result.MissingFiles.Add(fileName); - // Try to find file with relative path var alternativePaths = FindAlternativePaths(fileName, projectData.ProjectFilePath); result.PossibleAlternatives[fileName] = alternativePaths; } @@ -45,9 +63,17 @@ public static ProjectValidationResult ValidateProject (ProjectData projectData, return result; } + /// + /// Normalizes the specified file path by resolving the actual file name if the file is a persistence file. + /// + /// Use this method to obtain the canonical file path for files that may be persisted under a + /// different name. For files that do not have a ".lxp" extension, the input path is returned unchanged. + /// The path of the file to normalize. If the file has a ".lxp" extension, its persisted file name will be resolved; + /// otherwise, the original path is returned. + /// The normalized file path. If the input is a persistence file, returns the resolved file name; otherwise, returns + /// the original file path. private static string NormalizeFilePath (string fileName) { - // Handle .lxp files (persistence files) if (fileName.EndsWith(".lxp", StringComparison.OrdinalIgnoreCase)) { var persistenceData = Persister.Load(fileName); @@ -57,29 +83,37 @@ private static string NormalizeFilePath (string fileName) return fileName; } + /// + /// Determines whether the specified string represents an absolute URI with a scheme other than "file". + /// + /// This method returns false for local file paths and URIs with the "file" scheme, treating them + /// as regular files rather than remote resources. Common URI schemes include "http", "https", "ftp", and + /// "sftp". + /// The string to evaluate as a potential URI. Cannot be null, empty, or consist only of white-space characters. + /// true if the string is a valid absolute URI with a non-file scheme; otherwise, false. private static bool IsUri (string fileName) { - // Check if the string is a valid URI with a scheme (protocol) - // URIs typically have the format: scheme://path or scheme:/path - // Examples: sftp://server/file.log, http://example.com/log.txt - - if (string.IsNullOrWhiteSpace(fileName)) - { - return false; - } - - // Try to parse as URI - if (Uri.TryCreate(fileName, UriKind.Absolute, out var uri)) - { - // Check if it has a scheme other than file:// - // file:// URIs are local file paths and should be handled as regular files - return !string.IsNullOrEmpty(uri.Scheme) && - !uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase); - } - - return false; + return !string.IsNullOrWhiteSpace(fileName) && + Uri.TryCreate(fileName, UriKind.Absolute, out var uri) && + !string.IsNullOrEmpty(uri.Scheme) && + !uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase); } + /// + /// Searches for alternative file paths that may correspond to the specified file name, considering common locations + /// such as the project directory, its subdirectories, the user's Documents/LogExpert folder, alternate drive + /// letters, and relative paths from the project directory. + /// + /// This method attempts to locate files that may have been moved, renamed, or exist in typical + /// user or project directories. It ignores errors encountered during directory or file access and does not + /// guarantee that all possible alternative locations are checked. Duplicate paths are excluded from the + /// result. + /// The name or path of the file to search for. Can be an absolute or relative path. Cannot be null, empty, or + /// whitespace. + /// The full path to the project file used as a reference for searching related directories. Can be null or empty if + /// project context is not available. + /// A list of strings containing the full paths of files found that match the specified file name in alternative + /// locations. The list will be empty if no matching files are found. private static List FindAlternativePaths (string fileName, string projectFilePath) { var alternatives = new List(); @@ -104,7 +138,7 @@ private static List FindAlternativePaths (string fileName, string projec var projectDir = Path.GetDirectoryName(projectFilePath); if (!string.IsNullOrEmpty(projectDir) && Directory.Exists(projectDir)) { - var candidatePath = Path.Combine(projectDir, baseName); + var candidatePath = Path.Join(projectDir, baseName); if (File.Exists(candidatePath)) { alternatives.Add(candidatePath); @@ -114,7 +148,7 @@ private static List FindAlternativePaths (string fileName, string projec var subdirs = Directory.GetDirectories(projectDir); foreach (var subdir in subdirs) { - var subdirCandidate = Path.Combine(subdir, baseName); + var subdirCandidate = Path.Join(subdir, baseName); if (File.Exists(subdirCandidate)) { alternatives.Add(subdirCandidate); @@ -135,13 +169,11 @@ UnauthorizedAccessException or // Search in Documents/LogExpert folder try { - var documentsPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - "LogExpert"); + var documentsPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LogExpert"); if (Directory.Exists(documentsPath)) { - var docCandidate = Path.Combine(documentsPath, baseName); + var docCandidate = Path.Join(documentsPath, baseName); if (File.Exists(docCandidate) && !alternatives.Contains(docCandidate)) { alternatives.Add(docCandidate); @@ -201,7 +233,7 @@ UnauthorizedAccessException or var projectDir = Path.GetDirectoryName(projectFilePath); if (!string.IsNullOrEmpty(projectDir)) { - var relativePath = Path.Combine(projectDir, fileName); + var relativePath = Path.Join(projectDir, fileName); var normalizedPath = Path.GetFullPath(relativePath); if (File.Exists(normalizedPath) && !alternatives.Contains(normalizedPath)) @@ -223,4 +255,4 @@ IOException or return alternatives; } -} +} \ No newline at end of file From 0ac191dbc21f37e2c82597f201a1541f24630cba Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Mon, 15 Dec 2025 17:28:21 +0100 Subject: [PATCH 03/10] new choose files dialog for missing logfiles in the project lxp --- .../Classes/Persister/ProjectLoadResult.cs | 27 + .../Classes/Persister/ProjectPersister.cs | 36 +- .../LogExpert.Persister.Tests.csproj | 1 + .../ProjectFileValidatorTests.cs | 634 ++++++++++++++++++ src/LogExpert.Resources/Resources.Designer.cs | 72 ++ src/LogExpert.Resources/Resources.resx | 24 + src/LogExpert.UI/Dialogs/FileStatus.cs | 27 + .../Dialogs/LogTabWindow/LogTabWindow.cs | 295 +++++--- src/LogExpert.UI/Dialogs/MissingFileItem.cs | 61 ++ .../Dialogs/MissingFilesDialog.Designer.cs | 237 +++++++ .../Dialogs/MissingFilesDialog.cs | 367 ++++++++++ .../Dialogs/MissingFilesDialogResult.cs | 22 + .../Dialogs/MissingFilesMessageBox.cs | 56 ++ .../FileSystem/LocalFileSystem.cs | 6 + src/PluginRegistry/FileSystem/LogFileInfo.cs | 11 +- 15 files changed, 1780 insertions(+), 96 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs create mode 100644 src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs create mode 100644 src/LogExpert.UI/Dialogs/FileStatus.cs create mode 100644 src/LogExpert.UI/Dialogs/MissingFileItem.cs create mode 100644 src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs create mode 100644 src/LogExpert.UI/Dialogs/MissingFilesDialog.cs create mode 100644 src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs create mode 100644 src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs diff --git a/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs b/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs new file mode 100644 index 00000000..ecad42ba --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs @@ -0,0 +1,27 @@ +namespace LogExpert.Core.Classes.Persister; + +/// +/// Represents the result of loading a project file, including validation information. +/// +public class ProjectLoadResult +{ + /// + /// The loaded project data. + /// + public ProjectData ProjectData { get; set; } + + /// + /// Validation result containing valid, missing, and alternative file paths. + /// + public ProjectValidationResult ValidationResult { get; set; } + + /// + /// Indicates whether the project has at least one valid file to load. + /// + public bool HasValidFiles => ValidationResult?.ValidFiles.Count > 0; + + /// + /// Indicates whether user intervention is needed due to missing files. + /// + public bool RequiresUserIntervention => ValidationResult?.HasMissingFiles ?? false; +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs index 94ee4abd..5989589f 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs @@ -15,11 +15,12 @@ public static class ProjectPersister #region Public methods /// - /// Loads the project session data from a specified file. + /// Loads the project session data from a specified file, including validation of referenced files. /// - /// - /// - public static ProjectData LoadProjectData (string projectFileName, IPluginRegistry pluginRegistry) + /// The path to the project file (.lxj) + /// The plugin registry for file system validation + /// A containing the project data and validation results + public static ProjectLoadResult LoadProjectData (string projectFileName, IPluginRegistry pluginRegistry) { try { @@ -31,18 +32,37 @@ public static ProjectData LoadProjectData (string projectFileName, IPluginRegist var json = File.ReadAllText(projectFileName, Encoding.UTF8); var projectData = JsonConvert.DeserializeObject(json, settings); - var hasLayout = projectData.TabLayoutXml != null; + // Set project file path for alternative file search + projectData.ProjectFilePath = projectFileName; + + // Validate all files referenced in the project var validationResult = ProjectFileValidator.ValidateProject(projectData, pluginRegistry); - return projectData; + return new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or JsonSerializationException) { - _logger.Warn($"Error loading persistence data from {projectFileName}, trying old xml version"); - return ProjectPersisterXML.LoadProjectData(projectFileName); + + var projectData = ProjectPersisterXML.LoadProjectData(projectFileName); + + // Set project file path for alternative file search + projectData.ProjectFilePath = projectFileName; + + // Validate files from XML fallback as well + var validationResult = ProjectFileValidator.ValidateProject(projectData, pluginRegistry); + + return new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; } } diff --git a/src/LogExpert.Persister.Tests/LogExpert.Persister.Tests.csproj b/src/LogExpert.Persister.Tests/LogExpert.Persister.Tests.csproj index ae88c488..7a56ee24 100644 --- a/src/LogExpert.Persister.Tests/LogExpert.Persister.Tests.csproj +++ b/src/LogExpert.Persister.Tests/LogExpert.Persister.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs new file mode 100644 index 00000000..1e8c5928 --- /dev/null +++ b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs @@ -0,0 +1,634 @@ +using System.Globalization; + +using LogExpert.Core.Classes.Persister; + +namespace LogExpert.Persister.Tests; + +/// +/// Unit tests for the Project File Validator implementation (Issue #514). +/// Tests validation logic for missing files in project/session loading. +/// +[TestFixture] +public class ProjectFileValidatorTests +{ + private string _testDirectory; + private string _projectFile; + private List _testLogFiles; + + [SetUp] + public void Setup () + { + // Create temporary test directory + _testDirectory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ProjectValidator", Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory(_testDirectory); + + // Initialize test log files list + _testLogFiles = []; + + // Create a project file path (will be created in individual tests) + _projectFile = Path.Join(_testDirectory, "test_project.lxj"); + + // Initialize PluginRegistry for tests + _ = PluginRegistry.PluginRegistry.Create(_testDirectory, 1000); + } + + [TearDown] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Test")] + public void TearDown () + { + // Clean up test directory + if (Directory.Exists(_testDirectory)) + { + try + { + Directory.Delete(_testDirectory, true); + } + catch + { + // Ignore cleanup errors + } + } + } + + #region Helper Methods + + /// + /// Creates test log files with specified names. + /// + private void CreateTestLogFiles (params string[] fileNames) + { + foreach (var fileName in fileNames) + { + var filePath = Path.Join(_testDirectory, fileName); + File.WriteAllText(filePath, $"Test log content for {fileName}"); + _testLogFiles.Add(filePath); + } + } + + /// + /// Creates a test project file with specified log file references. + /// + private void CreateTestProjectFile (params string[] logFileNames) + { + var projectData = new ProjectData + { + FileNames = [.. logFileNames.Select(name => Path.Join(_testDirectory, name))], + TabLayoutXml = "test" + }; + + ProjectPersister.SaveProjectData(_projectFile, projectData); + } + + /// + /// Deletes specified log files to simulate missing files. + /// + private void DeleteLogFiles (params string[] fileNames) + { + foreach (var fileName in fileNames) + { + var filePath = Path.Join(_testDirectory, fileName); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + #endregion + + #region ProjectLoadResult Tests + + [Test] + public void ProjectLoadResult_HasValidFiles_AllFilesValid_ReturnsTrue () + { + // Arrange + var projectData = new ProjectData(); + var validationResult = new ProjectValidationResult(); + validationResult.ValidFiles.Add("file1.log"); + validationResult.ValidFiles.Add("file2.log"); + + var result = new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; + + // Act + var hasValidFiles = result.HasValidFiles; + + // Assert + Assert.That(hasValidFiles, Is.True, "Should have valid files"); + } + + [Test] + public void ProjectLoadResult_HasValidFiles_NoValidFiles_ReturnsFalse () + { + // Arrange + var projectData = new ProjectData(); + var validationResult = new ProjectValidationResult(); + validationResult.MissingFiles.Add("file1.log"); + validationResult.MissingFiles.Add("file2.log"); + + var result = new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; + + // Act + var hasValidFiles = result.HasValidFiles; + + // Assert + Assert.That(hasValidFiles, Is.False, "Should not have valid files"); + } + + [Test] + public void ProjectLoadResult_HasValidFiles_SomeValidFiles_ReturnsTrue () + { + // Arrange + var projectData = new ProjectData(); + var validationResult = new ProjectValidationResult(); + validationResult.ValidFiles.Add("file1.log"); + validationResult.MissingFiles.Add("file2.log"); + validationResult.MissingFiles.Add("file3.log"); + + var result = new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; + + // Act + var hasValidFiles = result.HasValidFiles; + + // Assert + Assert.That(hasValidFiles, Is.True, "Should have at least one valid file"); + } + + [Test] + public void ProjectLoadResult_RequiresUserIntervention_AllFilesValid_ReturnsFalse () + { + // Arrange + var projectData = new ProjectData(); + var validationResult = new ProjectValidationResult(); + validationResult.ValidFiles.Add("file1.log"); + validationResult.ValidFiles.Add("file2.log"); + + var result = new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; + + // Act + var requiresIntervention = result.RequiresUserIntervention; + + // Assert + Assert.That(requiresIntervention, Is.False, "Should not require user intervention"); + } + + [Test] + public void ProjectLoadResult_RequiresUserIntervention_SomeMissingFiles_ReturnsTrue () + { + // Arrange + var projectData = new ProjectData(); + var validationResult = new ProjectValidationResult(); + validationResult.ValidFiles.Add("file1.log"); + validationResult.MissingFiles.Add("file2.log"); + + var result = new ProjectLoadResult + { + ProjectData = projectData, + ValidationResult = validationResult + }; + + // Act + var requiresIntervention = result.RequiresUserIntervention; + + // Assert + Assert.That(requiresIntervention, Is.True, "Should require user intervention"); + } + + #endregion + + #region ProjectValidationResult Tests + + [Test] + public void ProjectValidationResult_HasMissingFiles_WithMissingFiles_ReturnsTrue () + { + // Arrange + var result = new ProjectValidationResult(); + result.ValidFiles.Add("file1.log"); + result.MissingFiles.Add("file2.log"); + + // Act + var hasMissing = result.HasMissingFiles; + + // Assert + Assert.That(hasMissing, Is.True, "Should have missing files"); + } + + [Test] + public void ProjectValidationResult_HasMissingFiles_WithoutMissingFiles_ReturnsFalse () + { + // Arrange + var result = new ProjectValidationResult(); + result.ValidFiles.Add("file1.log"); + result.ValidFiles.Add("file2.log"); + + // Act + var hasMissing = result.HasMissingFiles; + + // Assert + Assert.That(hasMissing, Is.False, "Should not have missing files"); + } + + #endregion + + #region ProjectPersister.LoadProjectData - All Files Valid + + [Test] + public void LoadProjectData_AllFilesExist_ReturnsSuccessResult () + { + // Arrange + CreateTestLogFiles("log1.log", "log2.log", "log3.log"); + CreateTestProjectFile("log1.log", "log2.log", "log3.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.ProjectData, Is.Not.Null, "ProjectData should not be null"); + Assert.That(result.ValidationResult, Is.Not.Null, "ValidationResult should not be null"); + Assert.That(result.HasValidFiles, Is.True, "Should have valid files"); + Assert.That(result.RequiresUserIntervention, Is.False, "Should not require intervention"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(3), "Should have 3 valid files"); + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(0), "Should have 0 missing files"); + } + + [Test] + public void LoadProjectData_AllFilesExist_ProjectDataContainsCorrectFiles () + { + // Arrange + CreateTestLogFiles("alpha.log", "beta.log", "gamma.log"); + CreateTestProjectFile("alpha.log", "beta.log", "gamma.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + var fileNames = result.ProjectData.FileNames.Select(Path.GetFileName).ToList(); + Assert.That(fileNames, Does.Contain("alpha.log"), "Should contain alpha.log"); + Assert.That(fileNames, Does.Contain("beta.log"), "Should contain beta.log"); + Assert.That(fileNames, Does.Contain("gamma.log"), "Should contain gamma.log"); + } + + [Test] + public void LoadProjectData_AllFilesExist_PreservesTabLayoutXml () + { + // Arrange + CreateTestLogFiles("test.log"); + CreateTestProjectFile("test.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result.ProjectData.TabLayoutXml, Is.Not.Null.And.Not.Empty, "TabLayoutXml should be preserved"); + Assert.That(result.ProjectData.TabLayoutXml, Does.Contain(""), "Should contain layout XML"); + } + + #endregion + + #region ProjectPersister.LoadProjectData - Some Files Missing + + [Test] + public void LoadProjectData_SomeFilesMissing_ReturnsPartialSuccessResult () + { + // Arrange + CreateTestLogFiles("exists1.log", "exists2.log", "missing.log"); + DeleteLogFiles("missing.log"); // Delete to simulate missing + CreateTestProjectFile("exists1.log", "exists2.log", "missing.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.HasValidFiles, Is.True, "Should have some valid files"); + Assert.That(result.RequiresUserIntervention, Is.True, "Should require user intervention"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(2), "Should have 2 valid files"); + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(1), "Should have 1 missing file"); + } + + [Test] + public void LoadProjectData_SomeFilesMissing_ValidFilesListIsCorrect () + { + // Arrange + CreateTestLogFiles("valid1.log", "valid2.log", "invalid.log"); + DeleteLogFiles("invalid.log"); + CreateTestProjectFile("valid1.log", "valid2.log", "invalid.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + var validFileNames = result.ValidationResult.ValidFiles.Select(Path.GetFileName).ToList(); + Assert.That(validFileNames, Does.Contain("valid1.log"), "Should contain valid1.log"); + Assert.That(validFileNames, Does.Contain("valid2.log"), "Should contain valid2.log"); + Assert.That(validFileNames, Does.Not.Contain("invalid.log"), "Should not contain invalid.log"); + } + + [Test] + public void LoadProjectData_SomeFilesMissing_MissingFilesListIsCorrect () + { + // Arrange + CreateTestLogFiles("present.log", "absent1.log", "absent2.log"); + DeleteLogFiles("absent1.log", "absent2.log"); + CreateTestProjectFile("present.log", "absent1.log", "absent2.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + var missingFileNames = result.ValidationResult.MissingFiles.Select(Path.GetFileName).ToList(); + Assert.That(missingFileNames, Does.Contain("absent1.log"), "Should contain absent1.log"); + Assert.That(missingFileNames, Does.Contain("absent2.log"), "Should contain absent2.log"); + Assert.That(missingFileNames, Does.Not.Contain("present.log"), "Should not contain present.log"); + } + + [Test] + public void LoadProjectData_MajorityFilesMissing_StillReturnsValidFiles () + { + // Arrange + CreateTestLogFiles("only_valid.log", "missing1.log", "missing2.log", "missing3.log", "missing4.log"); + DeleteLogFiles("missing1.log", "missing2.log", "missing3.log", "missing4.log"); + CreateTestProjectFile("only_valid.log", "missing1.log", "missing2.log", "missing3.log", "missing4.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result.HasValidFiles, Is.True, "Should have at least one valid file"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(1), "Should have 1 valid file"); + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(4), "Should have 4 missing files"); + } + + #endregion + + #region ProjectPersister.LoadProjectData - All Files Missing + + [Test] + public void LoadProjectData_AllFilesMissing_ReturnsFailureResult () + { + // Arrange + CreateTestLogFiles("missing1.log", "missing2.log"); + DeleteLogFiles("missing1.log", "missing2.log"); + CreateTestProjectFile("missing1.log", "missing2.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.HasValidFiles, Is.False, "Should not have valid files"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(0), "Should have 0 valid files"); + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(2), "Should have 2 missing files"); + } + + [Test] + public void LoadProjectData_AllFilesMissing_MissingFilesListComplete () + { + // Arrange + CreateTestLogFiles("gone1.log", "gone2.log", "gone3.log"); + DeleteLogFiles("gone1.log", "gone2.log", "gone3.log"); + CreateTestProjectFile("gone1.log", "gone2.log", "gone3.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(3), "Should have 3 missing files"); + var missingFileNames = result.ValidationResult.MissingFiles.Select(Path.GetFileName).ToList(); + Assert.That(missingFileNames, Does.Contain("gone1.log")); + Assert.That(missingFileNames, Does.Contain("gone2.log")); + Assert.That(missingFileNames, Does.Contain("gone3.log")); + } + + #endregion + + #region ProjectPersister.LoadProjectData - Empty/Invalid Projects + + [Test] + public void LoadProjectData_EmptyProject_ReturnsEmptyResult () + { + // Arrange + CreateTestProjectFile(); // Empty project with no files + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.ProjectData.FileNames, Is.Empty, "FileNames should be empty"); + Assert.That(result.ValidationResult.ValidFiles, Is.Empty, "ValidFiles should be empty"); + Assert.That(result.ValidationResult.MissingFiles, Is.Empty, "MissingFiles should be empty"); + } + + [Test] + public void LoadProjectData_NonExistentProjectFile_ReturnsNull () + { + // Arrange + var nonExistentProject = Path.Join(_testDirectory, "does_not_exist.lxj"); + + // Act + var result = ProjectPersister.LoadProjectData(nonExistentProject, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Null, "Result should be null for non-existent project file"); + } + + [Test] + public void LoadProjectData_CorruptedProjectFile_ReturnsNull () + { + // Arrange + var corruptedProject = Path.Join(_testDirectory, "corrupted.lxj"); + File.WriteAllText(corruptedProject, "This is not valid XML or JSON"); + + // Act + var result = ProjectPersister.LoadProjectData(corruptedProject, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Null, "Result should be null for corrupted project file"); + } + + #endregion + + #region Edge Cases and Special Scenarios + + [Test] + public void LoadProjectData_DuplicateFileReferences_HandlesCorrectly () + { + // Arrange + CreateTestLogFiles("duplicate.log"); + var projectData = new ProjectData + { + FileNames = + [ + Path.Join(_testDirectory, "duplicate.log"), + Path.Join(_testDirectory, "duplicate.log"), + Path.Join(_testDirectory, "duplicate.log") + ] + }; + ProjectPersister.SaveProjectData(_projectFile, projectData); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.HasValidFiles, Is.True, "Should have valid files"); + // Validation should handle duplicates gracefully + } + + [Test] + public void LoadProjectData_FilesWithSpecialCharacters_ValidatesCorrectly () + { + // Arrange + CreateTestLogFiles("file with spaces.log", "file-with-dashes.log", "file_with_underscores.log"); + CreateTestProjectFile("file with spaces.log", "file-with-dashes.log", "file_with_underscores.log"); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(3), "Should validate all files with special characters"); + } + + [Test] + public void LoadProjectData_VeryLargeProject_ValidatesEfficiently () + { + // Arrange + const int fileCount = 100; + var fileNames = new List(); + + for (int i = 0; i < fileCount; i++) + { + var fileName = $"log_{i:D4}.log"; + fileNames.Add(fileName); + } + + CreateTestLogFiles([.. fileNames]); + CreateTestProjectFile([.. fileNames]); + + // Act + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + stopwatch.Stop(); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(fileCount), $"Should validate all {fileCount} files"); + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(5000), "Should complete validation in reasonable time"); + } + + #endregion + + #region Performance and Stress Tests + + [Test] + public void LoadProjectData_ManyMissingFiles_PerformsEfficiently () + { + // Arrange + const int totalFiles = 50; + var fileNames = new List(); + + // Create only first 10 files, rest will be missing + for (int i = 0; i < 10; i++) + { + var fileName = $"exists_{i}.log"; + fileNames.Add(fileName); + CreateTestLogFiles(fileName); + } + + for (int i = 10; i < totalFiles; i++) + { + fileNames.Add($"missing_{i}.log"); + } + + CreateTestProjectFile([.. fileNames]); + + // Act + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + stopwatch.Stop(); + + // Assert + Assert.That(result, Is.Not.Null, "Result should not be null"); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(10), "Should have 10 valid files"); + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(40), "Should have 40 missing files"); + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(2000), "Should handle many missing files efficiently"); + } + + #endregion + + #region Null and Exception Handling + + [Test] + public void LoadProjectData_NullProjectFile_ThrowsArgumentNullException () + { + // Act & Assert + _ = Assert.Throws(() => + ProjectPersister.LoadProjectData(null, PluginRegistry.PluginRegistry.Instance)); + } + + [Test] + public void LoadProjectData_EmptyProjectFile_ThrowsArgumentException () + { + // Act & Assert + _ = Assert.Throws(() => ProjectPersister.LoadProjectData(string.Empty, PluginRegistry.PluginRegistry.Instance)); + } + + [Test] + public void LoadProjectData_NullPluginRegistry_ThrowsArgumentNullException () + { + // Arrange + CreateTestProjectFile("test.log"); + + // Act & Assert + _ = Assert.Throws(() => ProjectPersister.LoadProjectData(_projectFile, null)); + } + + #endregion + + #region Backward Compatibility + + [Test] + public void LoadProjectData_LegacyProjectFormat_StillWorks () + { + // Arrange + CreateTestLogFiles("legacy.log"); + + // Create a legacy format project file (XML) + var legacyXml = @" + + + + +"; + + var legacyContent = string.Format(CultureInfo.InvariantCulture, legacyXml, Path.Join(_testDirectory, "legacy.log")); + File.WriteAllText(_projectFile, legacyContent); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null, "Should handle legacy format"); + Assert.That(result.HasValidFiles, Is.True, "Should load legacy files"); + } + + #endregion +} \ No newline at end of file diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index f9797035..35a54613 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -1245,6 +1245,78 @@ public static string KeywordActionDlg_UI_Title { } } + /// + /// Looks up a localized string similar to Error loading project file. The file may be corrupted or inaccessible.. + /// + public static string LoadProject_UI_Message_Error_FileMaybeCorruptedOrInaccessible { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_FileMaybeCorruptedOrInaccessible", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to update session file: {0}. + /// + public static string LoadProject_UI_Message_Error_Message_FailedToUpdateSessionFile { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_Message_FailedToUpdateSessionFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Session file has been updated with the new file paths.. + /// + public static string LoadProject_UI_Message_Error_Message_UpdateSessionFile { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_Message_UpdateSessionFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Session Update Failed. + /// + public static string LoadProject_UI_Message_Error_Title_FailedToUpdateSessionFile { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_Title_FailedToUpdateSessionFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Project Load Failed. + /// + public static string LoadProject_UI_Message_Error_Title_ProjectLoadFailed { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_Title_ProjectLoadFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Session Load Failed. + /// + public static string LoadProject_UI_Message_Error_Title_SessionLoadFailed { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_Title_SessionLoadFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Session Updated. + /// + public static string LoadProject_UI_Message_Error_Title_UpdateSessionFile { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Error_Title_UpdateSessionFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to None of the files in this session could be found. The session cannot be loaded.. + /// + public static string LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound { + get { + return ResourceManager.GetString("LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not begin restart session. Unable to determine file locker.. /// diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index ff6ae79b..7cb06015 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -2074,4 +2074,28 @@ Restart LogExpert to apply changes? Default (single line) + + Error loading project file. The file may be corrupted or inaccessible. + + + Project Load Failed + + + None of the files in this session could be found. The session cannot be loaded. + + + Session Load Failed + + + Session file has been updated with the new file paths. + + + Session Updated + + + Failed to update session file: {0} + + + Session Update Failed + \ No newline at end of file diff --git a/src/LogExpert.UI/Dialogs/FileStatus.cs b/src/LogExpert.UI/Dialogs/FileStatus.cs new file mode 100644 index 00000000..cdf8f891 --- /dev/null +++ b/src/LogExpert.UI/Dialogs/FileStatus.cs @@ -0,0 +1,27 @@ +namespace LogExpert.UI.Dialogs; + +/// +/// Represents the status of a file in the missing files dialog. +/// +public enum FileStatus +{ + /// + /// File exists and is accessible. + /// + Valid, + + /// + /// File is missing but alternatives are available. + /// + MissingWithAlternatives, + + /// + /// File is missing and no alternatives found. + /// + Missing, + + /// + /// User has manually selected an alternative path. + /// + AlternativeSelected +} diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index f1383833..9041e569 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -585,20 +585,21 @@ public LogWindow.LogWindow AddFileTab (string givenFileName, bool isTempFile, st var data = logWindow.Tag as LogWindowData; data.Color = _defaultTabColor; - SetTabColor(logWindow, _defaultTabColor); + //TODO SetTabColor and the Coloring must be reimplemented with a different UI Framework + //SetTabColor(logWindow, _defaultTabColor); //data.tabPage.BorderColor = this.defaultTabBorderColor; - if (!isTempFile) - { - foreach (var colorEntry in ConfigManager.Settings.FileColors) - { - if (colorEntry.FileName.ToUpperInvariant().Equals(logFileName.ToUpperInvariant(), StringComparison.Ordinal)) - { - data.Color = colorEntry.Color; - SetTabColor(logWindow, colorEntry.Color); - break; - } - } - } + //if (!isTempFile) + //{ + // foreach (var colorEntry in ConfigManager.Settings.FileColors) + // { + // if (colorEntry.FileName.ToUpperInvariant().Equals(logFileName.ToUpperInvariant(), StringComparison.Ordinal)) + // { + // data.Color = colorEntry.Color; + // //SetTabColor(logWindow, colorEntry.Color); + // break; + // } + // } + //} if (!isTempFile) { @@ -1045,9 +1046,9 @@ private void DisconnectEventHandlers (LogWindow.LogWindow logWindow) [SupportedOSPlatform("windows")] private void AddToFileHistory (string fileName) { - bool FindName (string s) => s.ToUpperInvariant().Equals(fileName.ToUpperInvariant(), StringComparison.Ordinal); + bool findName (string s) => s.ToUpperInvariant().Equals(fileName.ToUpperInvariant(), StringComparison.Ordinal); - var index = ConfigManager.Settings.FileHistoryList.FindIndex(FindName); + var index = ConfigManager.Settings.FileHistoryList.FindIndex(findName); if (index != -1) { @@ -1372,7 +1373,7 @@ private void ChangeCurrentLogWindow (LogWindow.LogWindow newLogWindow) oldLogWindow.BookmarkAdded -= OnBookmarkAdded; oldLogWindow.BookmarkRemoved -= OnBookmarkRemoved; oldLogWindow.BookmarkTextChanged -= OnBookmarkTextChanged; - DisconnectToolWindows(oldLogWindow); + DisconnectToolWindows(); } if (newLogWindow != null) @@ -1434,13 +1435,12 @@ private void ConnectBookmarkWindow (LogWindow.LogWindow logWindow) _bookmarkWindow.SetCurrentFile(ctx); } - private void DisconnectToolWindows (LogWindow.LogWindow logWindow) + private void DisconnectToolWindows () { - DisconnectBookmarkWindow(logWindow); + DisconnectBookmarkWindow(); } - //TODO Find out if logwindow is necessary here - private void DisconnectBookmarkWindow (LogWindow.LogWindow logWindow) + private void DisconnectBookmarkWindow () { _bookmarkWindow.SetBookmarkData(null); _bookmarkWindow.SetCurrentFile(null); @@ -1460,6 +1460,7 @@ private void GuiStateUpdateWorker (GuiStateEventArgs e) multiFileToolStripMenuItem.Checked = e.IsMultiFileActive; multiFileEnabledStripMenuItem.Checked = e.IsMultiFileActive; cellSelectModeToolStripMenuItem.Checked = e.CellSelectMode; + RefreshEncodingMenuBar(e.CurrentEncoding); if (e.TimeshiftPossible && ConfigManager.Settings.Preferences.TimestampControl) @@ -1679,6 +1680,7 @@ private static int GetLevelFromDiff (int diff) } [SupportedOSPlatform("windows")] + //TODO Task based private void LedThreadProc () { Thread.CurrentThread.Name = "LED Thread"; @@ -2002,44 +2004,159 @@ private void CloseAllTabs () } //TODO Reimplementation needs a new UI Framework since, DockpanelSuite has no easy way to change TabColor - private static void SetTabColor (LogWindow.LogWindow logWindow, Color color) - { - //tabPage.BackLowColor = color; - //tabPage.BackLowColorDisabled = Color.FromArgb(255, - // Math.Max(0, color.R - 50), - // Math.Max(0, color.G - 50), - // Math.Max(0, color.B - 50) - // ); - } + //private static void SetTabColor (LogWindow.LogWindow logWindow, Color color) + //{ + // //tabPage.BackLowColor = color; + // //tabPage.BackLowColorDisabled = Color.FromArgb(255, + // // Math.Max(0, color.R - 50), + // // Math.Max(0, color.G - 50), + // // Math.Max(0, color.B - 50) + // // ); + //} [SupportedOSPlatform("windows")] private void LoadProject (string projectFileName, bool restoreLayout) { - var projectData = ProjectPersister.LoadProjectData(projectFileName, PluginRegistry.PluginRegistry.Instance); - var hasLayoutData = projectData.TabLayoutXml != null; - - if (hasLayoutData && restoreLayout && _logWindowList.Count > 0) + try { - ProjectLoadDlg dlg = new(); - if (DialogResult.Cancel != dlg.ShowDialog()) + _logger.Info($"Loading project from {projectFileName}"); + + // Load project with validation + var loadResult = ProjectPersister.LoadProjectData(projectFileName, PluginRegistry.PluginRegistry.Instance); + + // Check if project data was loaded + if (loadResult?.ProjectData == null) + { + _ = MessageBox.Show( + Resources.LoadProject_UI_Message_Error_FileMaybeCorruptedOrInaccessible, + Resources.LoadProject_UI_Message_Error_Title_ProjectLoadFailed, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + + var projectData = loadResult.ProjectData; + var hasLayoutData = projectData.TabLayoutXml != null; + + // Handle missing files + if (loadResult.RequiresUserIntervention) { - switch (dlg.ProjectLoadResult) + _logger.Warn($"Project has {loadResult.ValidationResult.MissingFiles.Count} missing files"); + + // If NO valid files AND NO alternatives, always cancel + if (!loadResult.HasValidFiles && loadResult.ValidationResult.PossibleAlternatives.Count == 0) + { + _ = MessageBox.Show( + Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound, + Resources.LoadProject_UI_Message_Error_Title_SessionLoadFailed, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + + // Show enhanced dialog with browsing capability + // This handles cases where: + // - Some files are valid and some are missing + // - All files are missing BUT alternatives are available + var dialogResult = MissingFilesDialog.ShowDialog(loadResult.ValidationResult, out var selectedAlternatives); + + if (dialogResult == MissingFilesDialogResult.Cancel) { - case ProjectLoadDlgResult.IgnoreLayout: - hasLayoutData = false; - break; - case ProjectLoadDlgResult.CloseTabs: - CloseAllTabs(); - break; - case ProjectLoadDlgResult.NewWindow: - LogExpertProxy.NewWindow([projectFileName]); - return; + return; + } + + // Apply selected alternatives + if (selectedAlternatives.Count > 0) + { + _logger.Info($"User selected {selectedAlternatives.Count} alternative paths"); + + // Replace original paths with selected alternatives in project data + for (int i = 0; i < projectData.FileNames.Count; i++) + { + var originalPath = projectData.FileNames[i]; + if (selectedAlternatives.TryGetValue(originalPath, out string value)) + { + projectData.FileNames[i] = value; + _logger.Info($"Replaced {Path.GetFileName(originalPath)} with {Path.GetFileName(value)}"); + } + } + + // Update session file if user requested + if (dialogResult == MissingFilesDialogResult.LoadAndUpdateSession) + { + ProjectPersister.SaveProjectData(projectFileName, projectData); + + _ = MessageBox.Show( + Resources.LoadProject_UI_Message_Error_Message_UpdateSessionFile, + Resources.LoadProject_UI_Message_Error_Title_UpdateSessionFile, + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + } + } + + // Load only valid files (original or replaced with alternatives) + _logger.Info($"Loading {loadResult.ValidationResult.ValidFiles.Count} valid files"); + + // Filter project data to only include valid files (considering alternatives) + var filesToLoad = new List(); + foreach (var fileName in projectData.FileNames) + { + // Check if this file exists (either original or alternative) + try + { + var fs = PluginRegistry.PluginRegistry.Instance.FindFileSystemForUri(fileName); + if (fs != null) + { + var fileInfo = fs.GetLogfileInfo(fileName); + if (fileInfo != null) + { + filesToLoad.Add(fileName); + } + } + } + catch (Exception ex) when (ex is FileNotFoundException or + DirectoryNotFoundException or + UnauthorizedAccessException or + IOException or + UriFormatException or + ArgumentException or + ArgumentNullException) + { + // File doesn't exist or can't be accessed, skip it + _logger.Warn($"Skipping inaccessible file: {fileName}"); + } + } + + projectData.FileNames = filesToLoad; + } + else + { + // All files valid - proceed normally + _logger.Info($"All {projectData.FileNames.Count} files found, loading project"); + } + + if (hasLayoutData && restoreLayout && _logWindowList.Count > 0) + { + //TODO Project load dialog needs to be replaced with the new dialog + ProjectLoadDlg dlg = new(); + if (DialogResult.Cancel != dlg.ShowDialog()) + { + switch (dlg.ProjectLoadResult) + { + case ProjectLoadDlgResult.IgnoreLayout: + hasLayoutData = false; + break; + case ProjectLoadDlgResult.CloseTabs: + CloseAllTabs(); + break; + case ProjectLoadDlgResult.NewWindow: + LogExpertProxy.NewWindow([projectFileName]); + return; + } } } - } - if (projectData != null) - { foreach (var fileName in projectData.FileNames) { _ = hasLayoutData @@ -2047,13 +2164,27 @@ private void LoadProject (string projectFileName, bool restoreLayout) : AddFileTab(fileName, false, null, true, null); } - if (hasLayoutData && restoreLayout) + // Restore layout only if we loaded at least one file + if (hasLayoutData && restoreLayout && _logWindowList.Count > 0) { + _logger.Info("Restoring layout"); // Re-creating tool (non-document) windows is needed because the DockPanel control would throw strange errors DestroyToolWindows(); InitToolWindows(); RestoreLayout(projectData.TabLayoutXml); } + else if (_logWindowList.Count == 0) + { + _logger.Warn("No files loaded, skipping layout restoration"); + } + } + catch (Exception ex) + { + _ = MessageBox.Show( + $"Error loading project: {ex.Message}", + Resources.LogExpert_Common_UI_Title_Error, + MessageBoxButtons.OK, + MessageBoxIcon.Error); } } @@ -2623,8 +2754,7 @@ private void OnFileSizeChanged (object sender, LogEventArgs e) //if (this.dockPanel.ActiveContent != null && // this.dockPanel.ActiveContent != sender || data.tailState != 0) - if ((CurrentLogWindow != null && - CurrentLogWindow != sender) || data.TailState != 0) + if (CurrentLogWindow != null && CurrentLogWindow != sender || data.TailState != 0) { data.Dirty = true; } @@ -2928,45 +3058,46 @@ private void OnCloseAllTabsToolStripMenuItemClick (object sender, EventArgs e) [SupportedOSPlatform("windows")] private void OnTabColorToolStripMenuItemClick (object sender, EventArgs e) { - var logWindow = dockPanel.ActiveContent as LogWindow.LogWindow; + //Todo TabColoring must be reimplemented with a different UI Framework + //var logWindow = dockPanel.ActiveContent as LogWindow.LogWindow; - if (logWindow.Tag is not LogWindowData data) - { - return; - } + //if (logWindow.Tag is not LogWindowData data) + //{ + // return; + //} - ColorDialog dlg = new() - { - Color = data.Color - }; + //ColorDialog dlg = new() + //{ + // Color = data.Color + //}; - if (dlg.ShowDialog() == DialogResult.OK) - { - data.Color = dlg.Color; - SetTabColor(logWindow, data.Color); - } + //if (dlg.ShowDialog() == DialogResult.OK) + //{ + // data.Color = dlg.Color; + // //SetTabColor(logWindow, data.Color); + //} - List delList = []; + //List delList = []; - foreach (var entry in ConfigManager.Settings.FileColors) - { - if (entry.FileName.Equals(logWindow.FileName, StringComparison.Ordinal)) - { - delList.Add(entry); - } - } + //foreach (var entry in ConfigManager.Settings.FileColors) + //{ + // if (entry.FileName.Equals(logWindow.FileName, StringComparison.Ordinal)) + // { + // delList.Add(entry); + // } + //} - foreach (var entry in delList) - { - _ = ConfigManager.Settings.FileColors.Remove(entry); - } + //foreach (var entry in delList) + //{ + // _ = ConfigManager.Settings.FileColors.Remove(entry); + //} - ConfigManager.Settings.FileColors.Add(new ColorEntry(logWindow.FileName, dlg.Color)); + //ConfigManager.Settings.FileColors.Add(new ColorEntry(logWindow.FileName, dlg.Color)); - while (ConfigManager.Settings.FileColors.Count > MAX_COLOR_HISTORY) - { - ConfigManager.Settings.FileColors.RemoveAt(0); - } + //while (ConfigManager.Settings.FileColors.Count > MAX_COLOR_HISTORY) + //{ + // ConfigManager.Settings.FileColors.RemoveAt(0); + //} } [SupportedOSPlatform("windows")] diff --git a/src/LogExpert.UI/Dialogs/MissingFileItem.cs b/src/LogExpert.UI/Dialogs/MissingFileItem.cs new file mode 100644 index 00000000..73c35497 --- /dev/null +++ b/src/LogExpert.UI/Dialogs/MissingFileItem.cs @@ -0,0 +1,61 @@ +namespace LogExpert.UI.Dialogs; + +/// +/// Represents a file item in the Missing Files Dialog ListView. +/// +public class MissingFileItem +{ + /// + /// Original file path from the session/project file. + /// + public string OriginalPath { get; set; } + + /// + /// Current status of the file. + /// + public FileStatus Status { get; set; } + + /// + /// List of alternative paths that might be the same file. + /// + public List Alternatives { get; set; } = []; + + /// + /// Currently selected path (original or alternative). + /// + public string SelectedPath { get; set; } + + /// + /// Indicates whether the file is accessible. + /// + public bool IsAccessible => Status is FileStatus.Valid or FileStatus.AlternativeSelected; + + /// + /// Gets the display name for the ListView (just the filename). + /// + public string DisplayName => Path.GetFileName(OriginalPath) ?? OriginalPath; + + /// + /// Gets the status text for display. + /// + public string StatusText => Status switch + { + FileStatus.Valid => "Found", + FileStatus.MissingWithAlternatives => $"Missing ({Alternatives.Count} alternatives)", + FileStatus.Missing => "Missing", + 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/MissingFilesDialog.Designer.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs new file mode 100644 index 00000000..12cb9a9e --- /dev/null +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs @@ -0,0 +1,237 @@ +using System.Runtime.Versioning; + +namespace LogExpert.UI.Dialogs; + +[SupportedOSPlatform("windows")] +partial class MissingFilesDialog +{ + /// + /// 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?.Dispose(); + imageListStatus?.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. + /// + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + + listViewFiles = new ListView(); + columnFileName = new ColumnHeader(); + columnStatus = new ColumnHeader(); + columnPath = new ColumnHeader(); + buttonLoadAndUpdate = new Button(); + buttonLoad = new Button(); + buttonBrowse = new Button(); + buttonCancel = new Button(); + labelInfo = new Label(); + labelSummary = new Label(); + imageListStatus = new ImageList(components); + panelButtons = new Panel(); + panelTop = new Panel(); + var buttonLayoutPanel = new FlowLayoutPanel(); + + SuspendLayout(); + + // + // imageListStatus + // + imageListStatus.ColorDepth = ColorDepth.Depth32Bit; + imageListStatus.ImageSize = new Size(16, 16); + CreateStatusIcons(); + + // + // panelTop + // + panelTop.Controls.Add(labelSummary); + panelTop.Controls.Add(labelInfo); + panelTop.Dock = DockStyle.Top; + panelTop.Height = 80; + panelTop.Padding = new Padding(10); + panelTop.TabIndex = 0; + + // + // labelInfo + // + labelInfo.AutoSize = false; + labelInfo.Dock = DockStyle.Top; + labelInfo.Height = 40; + labelInfo.Text = "Some files from the session could not be found. You can browse for missing files or load only the files that were found."; + labelInfo.TextAlign = ContentAlignment.MiddleLeft; + labelInfo.TabIndex = 0; + + // + // labelSummary + // + labelSummary.AutoSize = false; + labelSummary.Dock = DockStyle.Top; + labelSummary.Font = new Font(Font, FontStyle.Bold); + labelSummary.Height = 30; + labelSummary.TextAlign = ContentAlignment.MiddleLeft; + labelSummary.TabIndex = 1; + + // + // listViewFiles + // + listViewFiles.Columns.AddRange([ + columnFileName, + columnStatus, + columnPath]); + listViewFiles.Dock = DockStyle.Fill; + listViewFiles.FullRowSelect = true; + listViewFiles.GridLines = true; + listViewFiles.MultiSelect = false; + listViewFiles.SmallImageList = imageListStatus; + listViewFiles.TabIndex = 1; + listViewFiles.View = View.Details; + listViewFiles.SelectedIndexChanged += OnListViewSelectedIndexChanged; + listViewFiles.DoubleClick += OnListViewDoubleClick; + + // + // columnFileName + // + columnFileName.Text = "File Name"; + columnFileName.Width = 200; + + // + // columnStatus + // + columnStatus.Text = "Status"; + columnStatus.Width = 150; + + // + // columnPath + // + columnPath.Text = "Path"; + columnPath.Width = 400; + + // + // buttonLoad + // + buttonLoad.AutoSize = true; + buttonLoad.Height = 30; + buttonLoad.Margin = new Padding(3); + buttonLoad.MinimumSize = new Size(100, 30); + buttonLoad.TabIndex = 0; + buttonLoad.Text = "Load Files"; + buttonLoad.UseVisualStyleBackColor = true; + buttonLoad.Click += OnButtonLoadClick; + + // + // buttonBrowse + // + buttonBrowse.AutoSize = true; + buttonBrowse.Enabled = false; + buttonBrowse.Height = 30; + buttonBrowse.Margin = new Padding(3); + buttonBrowse.MinimumSize = new Size(100, 30); + buttonBrowse.TabIndex = 1; + buttonBrowse.Text = "Browse..."; + buttonBrowse.UseVisualStyleBackColor = true; + buttonBrowse.Click += OnButtonBrowseClick; + + // + // buttonLoadAndUpdate + // + buttonLoadAndUpdate.AutoSize = true; + buttonLoadAndUpdate.Enabled = false; + buttonLoadAndUpdate.Height = 30; + buttonLoadAndUpdate.Margin = new Padding(3); + buttonLoadAndUpdate.MinimumSize = new Size(150, 30); + buttonLoadAndUpdate.TabIndex = 2; + buttonLoadAndUpdate.Text = "Load && Update Session"; + buttonLoadAndUpdate.UseVisualStyleBackColor = true; + buttonLoadAndUpdate.Click += OnButtonLoadAndUpdateClick; + + // + // buttonCancel + // + buttonCancel.AutoSize = true; + buttonCancel.DialogResult = DialogResult.Cancel; + buttonCancel.Height = 30; + buttonCancel.Margin = new Padding(3); + buttonCancel.MinimumSize = new Size(100, 30); + buttonCancel.TabIndex = 3; + buttonCancel.Text = "Cancel"; + buttonCancel.UseVisualStyleBackColor = true; + buttonCancel.Click += OnButtonCancelClick; + + // + // buttonLayoutPanel + // + buttonLayoutPanel.AutoSize = true; + buttonLayoutPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink; + buttonLayoutPanel.Controls.Add(buttonLoad); + buttonLayoutPanel.Controls.Add(buttonBrowse); + buttonLayoutPanel.Controls.Add(buttonLoadAndUpdate); + buttonLayoutPanel.Controls.Add(buttonCancel); + buttonLayoutPanel.Dock = DockStyle.Right; + buttonLayoutPanel.FlowDirection = FlowDirection.LeftToRight; + buttonLayoutPanel.Location = new Point(0, 10); + buttonLayoutPanel.Padding = new Padding(10, 10, 10, 10); + buttonLayoutPanel.TabIndex = 0; + buttonLayoutPanel.WrapContents = false; + + // + // panelButtons + // + panelButtons.Controls.Add(buttonLayoutPanel); + panelButtons.Dock = DockStyle.Bottom; + panelButtons.Height = 60; + panelButtons.TabIndex = 2; + + // + // MissingFilesDialog + // + AcceptButton = buttonLoad; + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; + CancelButton = buttonCancel; + ClientSize = new Size(840, 500); + Controls.Add(listViewFiles); + Controls.Add(panelButtons); + Controls.Add(panelTop); + FormBorderStyle = FormBorderStyle.Sizable; + MinimumSize = new Size(600, 400); + ShowIcon = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + Text = "Missing Files"; + + ResumeLayout(false); + } + + #endregion + + private ListView listViewFiles; + private ColumnHeader columnFileName; + private ColumnHeader columnStatus; + private ColumnHeader columnPath; + private Button buttonLoad; + private Button buttonLoadAndUpdate; + private Button buttonBrowse; + private Button buttonCancel; + private Label labelInfo; + private Label labelSummary; + private ImageList imageListStatus; + private Panel panelButtons; + private Panel panelTop; +} diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs new file mode 100644 index 00000000..0a7306d9 --- /dev/null +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs @@ -0,0 +1,367 @@ +using System.Runtime.Versioning; + +using LogExpert.Core.Classes.Persister; + +namespace LogExpert.UI.Dialogs; + +/// +/// Enhanced dialog for handling missing files with browsing and alternative selection. +/// Phase 2 implementation of the Project File Validator. +/// +[SupportedOSPlatform("windows")] +public partial class MissingFilesDialog : Form +{ + #region Fields + + private readonly ProjectValidationResult _validationResult; + private readonly Dictionary _fileItems; + + #endregion + + #region Properties + + /// + /// Gets the dialog result indicating the user's choice. + /// + public MissingFilesDialogResult Result { get; private set; } + + /// + /// Gets the dictionary of selected alternative paths for missing files. + /// Key: original path, Value: selected alternative path + /// + public Dictionary SelectedAlternatives { get; private set; } + + #endregion + + #region Constructor + + /// + /// Constructor for MissingFilesDialog. + /// + /// Validation result containing file information + public MissingFilesDialog (ProjectValidationResult validationResult) + { + ArgumentNullException.ThrowIfNull(validationResult); + + _validationResult = validationResult; + _fileItems = []; + SelectedAlternatives = []; + Result = MissingFilesDialogResult.Cancel; + + InitializeComponent(); + InitializeFileItems(); + PopulateListView(); + UpdateSummary(); + } + + #endregion + + #region Public Methods + + /// + /// Shows the dialog and returns the user's choice. + /// + /// Validation result + /// Dialog result + public static MissingFilesDialogResult ShowDialog (ProjectValidationResult validationResult) + { + using var dialog = new MissingFilesDialog(validationResult); + _ = dialog.ShowDialog(); + return dialog.Result; + } + + /// + /// Shows the dialog and returns alternatives if selected. + /// + /// Validation result + /// Dictionary of selected alternatives + /// Dialog result + public static MissingFilesDialogResult ShowDialog (ProjectValidationResult validationResult, out Dictionary selectedAlternatives) + { + using var dialog = new MissingFilesDialog(validationResult); + _ = dialog.ShowDialog(); + selectedAlternatives = dialog.SelectedAlternatives; + return dialog.Result; + } + + #endregion + + #region Private Methods + + /// + /// Creates status icons for the ImageList. + /// + private void CreateStatusIcons () + { + // Create simple colored circles as status indicators + + // Valid - Green circle + var validIcon = new Bitmap(16, 16); + using (var g = Graphics.FromImage(validIcon)) + { + g.Clear(Color.Transparent); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.FillEllipse(Brushes.Green, 2, 2, 12, 12); + } + + imageListStatus.Images.Add("Valid", validIcon); + + // Missing - Red circle + var missingIcon = new Bitmap(16, 16); + using (var g = Graphics.FromImage(missingIcon)) + { + g.Clear(Color.Transparent); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.FillEllipse(Brushes.Red, 2, 2, 12, 12); + } + + imageListStatus.Images.Add("Missing", missingIcon); + + // Alternative available - Orange circle + var alternativeIcon = new Bitmap(16, 16); + using (var g = Graphics.FromImage(alternativeIcon)) + { + g.Clear(Color.Transparent); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.FillEllipse(Brushes.Orange, 2, 2, 12, 12); + } + + imageListStatus.Images.Add("Alternative", alternativeIcon); + + // Alternative selected - Blue circle + var selectedIcon = new Bitmap(16, 16); + using (var g = Graphics.FromImage(selectedIcon)) + { + g.Clear(Color.Transparent); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.FillEllipse(Brushes.Blue, 2, 2, 12, 12); + } + + imageListStatus.Images.Add("Selected", selectedIcon); + } + + /// + /// Initializes the file items dictionary from validation result. + /// + private void InitializeFileItems () + { + // Add valid files + foreach (var validPath in _validationResult.ValidFiles) + { + var item = new MissingFileItem(validPath, FileStatus.Valid); + _fileItems[validPath] = item; + } + + // Add missing files + foreach (var missingPath in _validationResult.MissingFiles) + { + var alternatives = _validationResult.PossibleAlternatives.TryGetValue(missingPath, out List? value) + ? value + : []; + + var status = alternatives.Count > 0 + ? FileStatus.MissingWithAlternatives + : FileStatus.Missing; + + var item = new MissingFileItem(missingPath, status) + { + Alternatives = alternatives + }; + + _fileItems[missingPath] = item; + } + } + + /// + /// Populates the ListView with file items. + /// + private void PopulateListView () + { + listViewFiles.BeginUpdate(); + listViewFiles.Items.Clear(); + + foreach (var fileItem in _fileItems.Values) + { + var listItem = new ListViewItem(fileItem.DisplayName) + { + Tag = fileItem, + ImageKey = fileItem.Status switch + { + FileStatus.Valid => "Valid", + FileStatus.MissingWithAlternatives => "Alternative", + FileStatus.AlternativeSelected => "Selected", + _ => "Missing" + } + }; + + _ = listItem.SubItems.Add(fileItem.StatusText); + _ = listItem.SubItems.Add(fileItem.SelectedPath); + + // Color code the row based on status + if (fileItem.Status == FileStatus.Missing) + { + listItem.ForeColor = Color.Red; + } + else if (fileItem.Status == FileStatus.MissingWithAlternatives) + { + listItem.ForeColor = Color.DarkOrange; + } + else if (fileItem.Status == FileStatus.AlternativeSelected) + { + listItem.ForeColor = Color.Blue; + } + + _ = listViewFiles.Items.Add(listItem); + } + + listViewFiles.EndUpdate(); + } + + /// + /// Updates the summary label and control states. + /// + private void UpdateSummary () + { + var validCount = _fileItems.Values.Count(f => f.IsAccessible); + var totalCount = _fileItems.Count; + var missingCount = totalCount - validCount; + + labelSummary.Text = $"Found: {validCount} of {totalCount} files ({missingCount} missing)"; + + // Enable "Load and Update Session" only if user has selected alternatives + var hasSelectedAlternatives = _fileItems.Values.Any(f => f.Status == FileStatus.AlternativeSelected); + buttonLoadAndUpdate.Enabled = hasSelectedAlternatives; + + // Update button text based on selection + if (hasSelectedAlternatives) + { + var alternativeCount = _fileItems.Values.Count(f => f.Status == FileStatus.AlternativeSelected); + buttonLoadAndUpdate.Text = $"Load && Update Session ({alternativeCount})"; + } + else + { + buttonLoadAndUpdate.Text = "Load && Update Session"; + } + } + + /// + /// Opens a file browser dialog for the specified missing file. + /// + /// The file item to browse for + private void BrowseForFile (MissingFileItem fileItem) + { + using var openFileDialog = new OpenFileDialog + { + Title = $"Locate: {fileItem.DisplayName}", + Filter = "Log Files (*.log;*.txt)|*.log;*.txt|All Files (*.*)|*.*", + FileName = fileItem.DisplayName, + CheckFileExists = true, + Multiselect = false + }; + + // Try to set initial directory from original path + try + { + var directory = Path.GetDirectoryName(fileItem.OriginalPath); + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + { + openFileDialog.InitialDirectory = directory; + } + } + catch + { + // Ignore if path is invalid + } + + if (openFileDialog.ShowDialog(this) == DialogResult.OK) + { + // User selected a file + fileItem.SelectedPath = openFileDialog.FileName; + fileItem.Status = FileStatus.AlternativeSelected; + + // Store the alternative + SelectedAlternatives[fileItem.OriginalPath] = fileItem.SelectedPath; + + // Refresh the ListView + PopulateListView(); + UpdateSummary(); + } + } + + #endregion + + #region Event Handlers + + private void OnListViewSelectedIndexChanged (object sender, EventArgs e) + { + if (listViewFiles.SelectedItems.Count > 0) + { + var selectedItem = listViewFiles.SelectedItems[0]; + var fileItem = selectedItem.Tag as MissingFileItem; + + // Enable browse button for any file that is not valid (allow browsing/re-browsing) + buttonBrowse.Enabled = fileItem?.Status is + FileStatus.Missing or + FileStatus.MissingWithAlternatives or + FileStatus.AlternativeSelected; + } + else + { + buttonBrowse.Enabled = false; + } + } + + private void OnListViewDoubleClick (object sender, EventArgs e) + { + // Double-click to browse for missing file + if (listViewFiles.SelectedItems.Count > 0) + { + var selectedItem = listViewFiles.SelectedItems[0]; + var fileItem = selectedItem.Tag as MissingFileItem; + + if (fileItem?.Status is + FileStatus.Missing or + FileStatus.MissingWithAlternatives or + FileStatus.AlternativeSelected) + { + BrowseForFile(fileItem); + } + } + } + + private void OnButtonBrowseClick (object sender, EventArgs e) + { + if (listViewFiles.SelectedItems.Count > 0) + { + var selectedItem = listViewFiles.SelectedItems[0]; + + if (selectedItem.Tag is MissingFileItem fileItem) + { + BrowseForFile(fileItem); + } + } + } + + private void OnButtonLoadClick (object sender, EventArgs e) + { + Result = MissingFilesDialogResult.LoadValidFiles; + DialogResult = DialogResult.OK; + Close(); + } + + private void OnButtonLoadAndUpdateClick (object sender, EventArgs e) + { + Result = MissingFilesDialogResult.LoadAndUpdateSession; + DialogResult = DialogResult.OK; + Close(); + } + + private void OnButtonCancelClick (object sender, EventArgs e) + { + Result = MissingFilesDialogResult.Cancel; + DialogResult = DialogResult.Cancel; + Close(); + } + + #endregion +} diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs new file mode 100644 index 00000000..09c64470 --- /dev/null +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs @@ -0,0 +1,22 @@ +namespace LogExpert.UI.Dialogs; + +/// +/// Represents the result of the Missing Files Dialog interaction. +/// +public enum MissingFilesDialogResult +{ + /// + /// User cancelled the operation. + /// + Cancel, + + /// + /// Load only the valid files that were found. + /// + LoadValidFiles, + + /// + /// Load valid files and update the session file with new paths. + /// + LoadAndUpdateSession +} diff --git a/src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs b/src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs new file mode 100644 index 00000000..da39de1f --- /dev/null +++ b/src/LogExpert.UI/Dialogs/MissingFilesMessageBox.cs @@ -0,0 +1,56 @@ +using System.Runtime.Versioning; +using System.Text; + +using LogExpert.Core.Classes.Persister; + +namespace LogExpert.UI.Dialogs; + +/// +/// Temporary helper for showing missing file alerts until full dialog is implemented. +/// This provides a simple MessageBox-based notification system for Phase 1 of the implementation. +/// +[SupportedOSPlatform("windows")] +internal static class MissingFilesMessageBox +{ + /// + /// Shows a message box alerting the user about missing files from a project/session. + /// + /// The validation result containing missing file information + /// True if user wants to continue loading valid files, false to cancel + public static bool Show (ProjectValidationResult validationResult) + { + ArgumentNullException.ThrowIfNull(validationResult); + + var sb = new StringBuilder(); + _ = sb.AppendLine("Some files from the session could not be found:"); + _ = sb.AppendLine(); + + // Show first 10 missing files + var displayCount = Math.Min(10, validationResult.MissingFiles.Count); + for (var i = 0; i < displayCount; i++) + { + var missing = validationResult.MissingFiles[i]; + _ = sb.AppendLine($" • {Path.GetFileName(missing)}"); + } + + // If there are more than 10, show count of remaining + if (validationResult.MissingFiles.Count > 10) + { + _ = sb.AppendLine($" ... and {validationResult.MissingFiles.Count - 10} more"); + } + + _ = sb.AppendLine(); + var totalFiles = validationResult.ValidFiles.Count + validationResult.MissingFiles.Count; + _ = sb.AppendLine($"Found: {validationResult.ValidFiles.Count} of {totalFiles} files"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Do you want to load the files that were found?"); + + var result = MessageBox.Show( + sb.ToString(), + "Missing Files", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + + return result == DialogResult.Yes; + } +} diff --git a/src/PluginRegistry/FileSystem/LocalFileSystem.cs b/src/PluginRegistry/FileSystem/LocalFileSystem.cs index c684bb87..262f33df 100644 --- a/src/PluginRegistry/FileSystem/LocalFileSystem.cs +++ b/src/PluginRegistry/FileSystem/LocalFileSystem.cs @@ -21,6 +21,12 @@ ArgumentNullException or } } + /// + /// Retrieves information about a log file specified by a file URI. + /// + /// The URI string that identifies the log file. Must be a valid file URI. + /// An object that provides information about the specified log file. + /// Thrown if the provided URI string does not represent a file URI. public ILogFileInfo GetLogfileInfo (string uriString) { Uri uri = new(uriString); diff --git a/src/PluginRegistry/FileSystem/LogFileInfo.cs b/src/PluginRegistry/FileSystem/LogFileInfo.cs index ef7e12c5..bfcdbe00 100644 --- a/src/PluginRegistry/FileSystem/LogFileInfo.cs +++ b/src/PluginRegistry/FileSystem/LogFileInfo.cs @@ -10,7 +10,7 @@ public class LogFileInfo : ILogFileInfo private const int RETRY_COUNT = 5; private const int RETRY_SLEEP = 250; - private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); //FileStream fStream; private readonly FileInfo fInfo; @@ -21,7 +21,7 @@ public class LogFileInfo : ILogFileInfo #region cTor - public LogFileInfo(Uri fileUri) + public LogFileInfo (Uri fileUri) { fInfo = new FileInfo(fileUri.LocalPath); Uri = fileUri; @@ -37,7 +37,6 @@ public LogFileInfo(Uri fileUri) public string FileName => fInfo.Name; - public string DirectoryName => fInfo.DirectoryName; public char DirectorySeparatorChar => Path.DirectorySeparatorChar; @@ -124,7 +123,7 @@ public long LengthWithoutRetry /// rollover situations. /// /// - public Stream OpenStream() + public Stream OpenStream () { var retry = RETRY_COUNT; @@ -158,7 +157,7 @@ public Stream OpenStream() } //TODO Replace with Event from FileSystemWatcher - public bool FileHasChanged() + public bool FileHasChanged () { if (LengthWithoutRetry != lastLength) { @@ -169,7 +168,7 @@ public bool FileHasChanged() return false; } - public override string ToString() + public override string ToString () { return fInfo.FullName + ", OldLen: " + OriginalLength + ", Len: " + Length; } From 2cecf4d6c7bfa5910995eebbf512ee1a4f11cb82 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Mon, 15 Dec 2025 21:43:26 +0100 Subject: [PATCH 04/10] some fixes, some bugs not working --- .../Persister/ProjectValidationResult.cs | 8 +- src/LogExpert.Resources/Resources.Designer.cs | 189 +++++++++++++----- src/LogExpert.Resources/Resources.de.resx | 12 +- src/LogExpert.Resources/Resources.resx | 39 +++- .../Dialogs/LogTabWindow/LogTabWindow.cs | 106 +++++----- .../Dialogs/MissingFilesDialog.Designer.cs | 90 ++++++++- .../Dialogs/MissingFilesDialog.cs | 97 +++++++-- .../Dialogs/MissingFilesDialog.resx | 120 +++++++++++ .../Dialogs/MissingFilesDialogResult.cs | 35 +++- src/LogExpert.UI/Dialogs/ProjectLoadDlg.cs | 12 +- 10 files changed, 556 insertions(+), 152 deletions(-) create mode 100644 src/LogExpert.UI/Dialogs/MissingFilesDialog.resx diff --git a/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs b/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs index b98be6cf..fed2d742 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectValidationResult.cs @@ -2,9 +2,11 @@ namespace LogExpert.Core.Classes.Persister; public class ProjectValidationResult { - public List ValidFiles { get; } = new(); - public List MissingFiles { get; } = new(); - public Dictionary> PossibleAlternatives { get; } = new(); + public List ValidFiles { get; } = []; + + public List MissingFiles { get; } = []; + + public Dictionary> PossibleAlternatives { get; } = []; public bool HasMissingFiles => MissingFiles.Count > 0; } diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 35a54613..7c7c5754 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -3767,6 +3767,141 @@ public static string LogWindow_UI_WriteFilterToTab_NamePrefix_ForFilter { } } + /// + /// Looks up a localized string similar to Close existing tabs. + /// + public static string MissingFilesDialog_UI_Button_CloseTabs { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Button_CloseTabs", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ignore layout data. + /// + public static string MissingFilesDialog_UI_Button_Ignore { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Button_Ignore", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Load && Update Session. + /// + public static string MissingFilesDialog_UI_Button_LoadUpdateSession { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Button_LoadUpdateSession", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open new window. + /// + public static string MissingFilesDialog_UI_Button_NewWindow { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Button_NewWindow", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Load && Update Session ({0}). + /// + public static string MissingFilesDialog_UI_Button_UpdateSessionAlternativeCount { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Button_UpdateSessionAlternativeCount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Alternative. + /// + public static string MissingFilesDialog_UI_FileStatus_Alternative { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_FileStatus_Alternative", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing. + /// + public static string MissingFilesDialog_UI_FileStatus_Missing { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_FileStatus_Missing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Selected. + /// + public static string MissingFilesDialog_UI_FileStatus_Selected { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_FileStatus_Selected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Valid. + /// + public static string MissingFilesDialog_UI_FileStatus_Valid { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_FileStatus_Valid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Log Files {0}|All Files {2}. + /// + public static string MissingFilesDialog_UI_Filter_Logfiles { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Filter_Logfiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Locate: {0}. + /// + public static string MissingFilesDialog_UI_Filter_Title { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Filter_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please choose how to proceed:. + /// + public static string MissingFilesDialog_UI_Label_ChooseHowToProceed { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Label_ChooseHowToProceed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Restoring layout requires an empty workbench.. + /// + public static string MissingFilesDialog_UI_Label_Informational { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Label_Informational", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found: {0} of {1} files ({2} missing). + /// + public static string MissingFilesDialog_UI_Label_Summary { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Label_Summary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Loading Session. + /// + public static string MissingFilesDialog_UI_Title { + get { + return ResourceManager.GetString("MissingFilesDialog_UI_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to File name pattern:. /// @@ -4462,60 +4597,6 @@ public static string Program_UI_Error_Pipe_CannotConnectToFirstInstance { } } - /// - /// Looks up a localized string similar to Close existing tabs. - /// - public static string ProjectLoadDlg_UI_Button_CloseTabs { - get { - return ResourceManager.GetString("ProjectLoadDlg_UI_Button_CloseTabs", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Ignore layout data. - /// - public static string ProjectLoadDlg_UI_Button_Ignore { - get { - return ResourceManager.GetString("ProjectLoadDlg_UI_Button_Ignore", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open new window. - /// - public static string ProjectLoadDlg_UI_Button_NewWindow { - get { - return ResourceManager.GetString("ProjectLoadDlg_UI_Button_NewWindow", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please choose how to proceed:. - /// - public static string ProjectLoadDlg_UI_Label_ChooseHowToProceed { - get { - return ResourceManager.GetString("ProjectLoadDlg_UI_Label_ChooseHowToProceed", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Restoring layout requires an empty workbench.. - /// - public static string ProjectLoadDlg_UI_Label_Informational { - get { - return ResourceManager.GetString("ProjectLoadDlg_UI_Label_Informational", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Loading Session. - /// - public static string ProjectLoadDlg_UI_Title { - get { - return ResourceManager.GetString("ProjectLoadDlg_UI_Title", resourceCulture); - } - } - /// /// Looks up a localized string similar to RegEx.htm. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 10814e45..04e2e39f 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1689,22 +1689,22 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Start: {0} Ende: {1} - + Sitzung laden - + Das Wiederherstellen des Layouts erfordert eine leere Arbeitsfläche. - + Bitte wählen Sie, wie Sie fortfahren möchten: - + Vorhandene Tabs schließen - + Neues Fenster öffnen - + Layoutdaten ignorieren diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index 7cb06015..a98428e7 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -1692,22 +1692,22 @@ Checked tools will appear in the icon bar. All other tools are available in the Start: {0} End: {1} - + Loading Session - + Restoring layout requires an empty workbench. - + Please choose how to proceed: - + Close existing tabs - + Open new window - + Ignore layout data @@ -2098,4 +2098,31 @@ Restart LogExpert to apply changes? Session Update Failed + + Valid + + + Alternative + + + Selected + + + Missing + + + Found: {0} of {1} files ({2} missing) + + + Load && Update Session ({0}) + + + Load && Update Session + + + Log Files {0}|All Files {2} + + + Locate: {0} + \ No newline at end of file diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 9041e569..57126805 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -2014,6 +2014,7 @@ private void CloseAllTabs () // // ); //} + [SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")] private void LoadProject (string projectFileName, bool restoreLayout) { @@ -2038,13 +2039,11 @@ private void LoadProject (string projectFileName, bool restoreLayout) var projectData = loadResult.ProjectData; var hasLayoutData = projectData.TabLayoutXml != null; - // Handle missing files + // Handle missing files or layout options if (loadResult.RequiresUserIntervention) { - _logger.Warn($"Project has {loadResult.ValidationResult.MissingFiles.Count} missing files"); - // If NO valid files AND NO alternatives, always cancel - if (!loadResult.HasValidFiles && loadResult.ValidationResult.PossibleAlternatives.Count == 0) + if (loadResult.RequiresUserIntervention && !loadResult.HasValidFiles && loadResult.ValidationResult.PossibleAlternatives.Count == 0) { _ = MessageBox.Show( Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound, @@ -2054,17 +2053,31 @@ private void LoadProject (string projectFileName, bool restoreLayout) return; } - // Show enhanced dialog with browsing capability - // This handles cases where: - // - Some files are valid and some are missing - // - All files are missing BUT alternatives are available - var dialogResult = MissingFilesDialog.ShowDialog(loadResult.ValidationResult, out var selectedAlternatives); + // Show enhanced dialog with browsing capability and layout options + var dialogResult = MissingFilesDialog.ShowDialog( + loadResult.ValidationResult, + hasLayoutData, + out var selectedAlternatives); if (dialogResult == MissingFilesDialogResult.Cancel) { return; } + // Handle layout-related results + switch (dialogResult) + { + case MissingFilesDialogResult.CloseTabsAndRestoreLayout: + CloseAllTabs(); + break; + case MissingFilesDialogResult.OpenInNewWindow: + LogExpertProxy.NewWindow([.. projectData.FileNames]); + return; + case MissingFilesDialogResult.IgnoreLayout: + hasLayoutData = false; + break; + } + // Apply selected alternatives if (selectedAlternatives.Count > 0) { @@ -2091,44 +2104,46 @@ private void LoadProject (string projectFileName, bool restoreLayout) Resources.LoadProject_UI_Message_Error_Title_UpdateSessionFile, MessageBoxButtons.OK, MessageBoxIcon.Information); - } } // Load only valid files (original or replaced with alternatives) - _logger.Info($"Loading {loadResult.ValidationResult.ValidFiles.Count} valid files"); - - // Filter project data to only include valid files (considering alternatives) - var filesToLoad = new List(); - foreach (var fileName in projectData.FileNames) + if (loadResult.RequiresUserIntervention) { - // Check if this file exists (either original or alternative) - try + _logger.Info($"Loading {loadResult.ValidationResult.ValidFiles.Count} valid files"); + + // Filter project data to only include valid files (considering alternatives) + var filesToLoad = new List(); + foreach (var fileName in projectData.FileNames) { - var fs = PluginRegistry.PluginRegistry.Instance.FindFileSystemForUri(fileName); - if (fs != null) + // Check if this file exists (either original or alternative) + try { - var fileInfo = fs.GetLogfileInfo(fileName); - if (fileInfo != null) + var fs = PluginRegistry.PluginRegistry.Instance.FindFileSystemForUri(fileName); + if (fs != null) { - filesToLoad.Add(fileName); + var fileInfo = fs.GetLogfileInfo(fileName); + if (fileInfo != null) + { + filesToLoad.Add(fileName); + } } } + catch (Exception ex) when (ex is FileNotFoundException or + DirectoryNotFoundException or + UnauthorizedAccessException or + IOException or + UriFormatException or + ArgumentException or + ArgumentNullException) + { + // File doesn't exist or can't be accessed, skip it + _logger.Warn($"Skipping inaccessible file: {fileName}"); + } } - catch (Exception ex) when (ex is FileNotFoundException or - DirectoryNotFoundException or - UnauthorizedAccessException or - IOException or - UriFormatException or - ArgumentException or - ArgumentNullException) - { - // File doesn't exist or can't be accessed, skip it - _logger.Warn($"Skipping inaccessible file: {fileName}"); - } - } - projectData.FileNames = filesToLoad; + projectData.FileNames = filesToLoad; + } } else { @@ -2136,27 +2151,6 @@ ArgumentException or _logger.Info($"All {projectData.FileNames.Count} files found, loading project"); } - if (hasLayoutData && restoreLayout && _logWindowList.Count > 0) - { - //TODO Project load dialog needs to be replaced with the new dialog - ProjectLoadDlg dlg = new(); - if (DialogResult.Cancel != dlg.ShowDialog()) - { - switch (dlg.ProjectLoadResult) - { - case ProjectLoadDlgResult.IgnoreLayout: - hasLayoutData = false; - break; - case ProjectLoadDlgResult.CloseTabs: - CloseAllTabs(); - break; - case ProjectLoadDlgResult.NewWindow: - LogExpertProxy.NewWindow([projectFileName]); - return; - } - } - } - foreach (var fileName in projectData.FileNames) { _ = hasLayoutData diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs index 12cb9a9e..1ae13b31 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.Designer.cs @@ -47,9 +47,17 @@ private void InitializeComponent() imageListStatus = new ImageList(components); panelButtons = new Panel(); panelTop = new Panel(); - var buttonLayoutPanel = new FlowLayoutPanel(); + panelLayoutOptions = new Panel(); + labelLayoutInfo = new Label(); + radioButtonCloseTabs = new RadioButton(); + radioButtonNewWindow = new RadioButton(); + radioButtonIgnoreLayout = new RadioButton(); + buttonLayoutPanel = new FlowLayoutPanel(); SuspendLayout(); + panelLayoutOptions.SuspendLayout(); + panelTop.SuspendLayout(); + panelButtons.SuspendLayout(); // // imageListStatus @@ -88,13 +96,67 @@ private void InitializeComponent() labelSummary.TextAlign = ContentAlignment.MiddleLeft; labelSummary.TabIndex = 1; + // + // labelLayoutInfo + // + labelLayoutInfo.AutoSize = false; + labelLayoutInfo.Location = new Point(10, 5); + labelLayoutInfo.Size = new Size(400, 25); + labelLayoutInfo.Text = "This session contains layout data. How would you like to proceed?"; + labelLayoutInfo.TextAlign = ContentAlignment.MiddleLeft; + labelLayoutInfo.TabIndex = 0; + + // + // radioButtonCloseTabs + // + radioButtonCloseTabs.AutoSize = true; + radioButtonCloseTabs.Checked = true; + radioButtonCloseTabs.Location = new Point(10, 35); + radioButtonCloseTabs.Name = "radioButtonCloseTabs"; + radioButtonCloseTabs.Size = new Size(200, 24); + radioButtonCloseTabs.TabIndex = 1; + radioButtonCloseTabs.TabStop = true; + radioButtonCloseTabs.Text = "Close existing tabs and restore layout"; + radioButtonCloseTabs.UseVisualStyleBackColor = true; + + // + // radioButtonNewWindow + // + radioButtonNewWindow.AutoSize = true; + radioButtonNewWindow.Location = new Point(10, 60); + radioButtonNewWindow.Name = "radioButtonNewWindow"; + radioButtonNewWindow.Size = new Size(200, 24); + radioButtonNewWindow.TabIndex = 2; + radioButtonNewWindow.Text = "Open in a new window"; + radioButtonNewWindow.UseVisualStyleBackColor = true; + + // + // radioButtonIgnoreLayout + // + radioButtonIgnoreLayout.AutoSize = true; + radioButtonIgnoreLayout.Location = new Point(10, 85); + radioButtonIgnoreLayout.Name = "radioButtonIgnoreLayout"; + radioButtonIgnoreLayout.Size = new Size(200, 24); + radioButtonIgnoreLayout.TabIndex = 3; + radioButtonIgnoreLayout.Text = "Ignore layout data"; + radioButtonIgnoreLayout.UseVisualStyleBackColor = true; + + // + // panelLayoutOptions + // + panelLayoutOptions.Controls.Add(radioButtonIgnoreLayout); + panelLayoutOptions.Controls.Add(radioButtonNewWindow); + panelLayoutOptions.Controls.Add(radioButtonCloseTabs); + panelLayoutOptions.Controls.Add(labelLayoutInfo); + panelLayoutOptions.Dock = DockStyle.Bottom; + panelLayoutOptions.Height = 115; + panelLayoutOptions.TabIndex = 3; + panelLayoutOptions.Visible = false; + // // listViewFiles // - listViewFiles.Columns.AddRange([ - columnFileName, - columnStatus, - columnPath]); + listViewFiles.Columns.AddRange(columnFileName, columnStatus, columnPath); listViewFiles.Dock = DockStyle.Fill; listViewFiles.FullRowSelect = true; listViewFiles.GridLines = true; @@ -206,9 +268,10 @@ private void InitializeComponent() AutoScaleMode = AutoScaleMode.Dpi; CancelButton = buttonCancel; ClientSize = new Size(840, 500); - Controls.Add(listViewFiles); - Controls.Add(panelButtons); - Controls.Add(panelTop); + Controls.Add(listViewFiles); + Controls.Add(panelLayoutOptions); + Controls.Add(panelButtons); + Controls.Add(panelTop); FormBorderStyle = FormBorderStyle.Sizable; MinimumSize = new Size(600, 400); ShowIcon = false; @@ -216,6 +279,11 @@ private void InitializeComponent() StartPosition = FormStartPosition.CenterParent; Text = "Missing Files"; + panelLayoutOptions.ResumeLayout(false); + panelLayoutOptions.PerformLayout(); + panelTop.ResumeLayout(false); + panelButtons.ResumeLayout(false); + panelButtons.PerformLayout(); ResumeLayout(false); } @@ -234,4 +302,10 @@ private void InitializeComponent() private ImageList imageListStatus; private Panel panelButtons; private Panel panelTop; + private Panel panelLayoutOptions; + private Label labelLayoutInfo; + private RadioButton radioButtonCloseTabs; + private RadioButton radioButtonNewWindow; + private RadioButton radioButtonIgnoreLayout; + private FlowLayoutPanel buttonLayoutPanel; } diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs index 0a7306d9..55fcc995 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Runtime.Versioning; using LogExpert.Core.Classes.Persister; @@ -6,6 +7,7 @@ namespace LogExpert.UI.Dialogs; /// /// Enhanced dialog for handling missing files with browsing and alternative selection. +/// Also handles layout restoration options when loading a project with existing tabs. /// Phase 2 implementation of the Project File Validator. /// [SupportedOSPlatform("windows")] @@ -15,6 +17,7 @@ public partial class MissingFilesDialog : Form private readonly ProjectValidationResult _validationResult; private readonly Dictionary _fileItems; + private readonly bool _hasLayoutData; #endregion @@ -39,7 +42,9 @@ public partial class MissingFilesDialog : Form /// Constructor for MissingFilesDialog. /// /// Validation result containing file information - public MissingFilesDialog (ProjectValidationResult validationResult) + /// Whether to show layout restoration options + /// Whether the project has layout data to restore + public MissingFilesDialog (ProjectValidationResult validationResult, bool hasLayoutData = false) { ArgumentNullException.ThrowIfNull(validationResult); @@ -47,11 +52,13 @@ public MissingFilesDialog (ProjectValidationResult validationResult) _fileItems = []; SelectedAlternatives = []; Result = MissingFilesDialogResult.Cancel; + _hasLayoutData = hasLayoutData; InitializeComponent(); InitializeFileItems(); PopulateListView(); UpdateSummary(); + ConfigureLayoutOptions(); } #endregion @@ -84,10 +91,43 @@ public static MissingFilesDialogResult ShowDialog (ProjectValidationResult valid return dialog.Result; } + /// + /// Shows the dialog with layout options and returns alternatives if selected. + /// + /// Validation result + /// Whether to show layout restoration options + /// Whether the project has layout data + /// Dictionary of selected alternatives + /// Dialog result + public static MissingFilesDialogResult ShowDialog (ProjectValidationResult validationResult, bool hasLayoutData, out Dictionary selectedAlternatives) + { + using var dialog = new MissingFilesDialog(validationResult, hasLayoutData); + _ = dialog.ShowDialog(); + selectedAlternatives = dialog.SelectedAlternatives; + return dialog.Result; + } + #endregion #region Private Methods + /// + /// Configures visibility and state of layout options panel. + /// + private void ConfigureLayoutOptions () + { + Text = Resources.MissingFilesDialog_UI_Title; + + panelLayoutOptions.Visible = true; + labelLayoutInfo.Text = Resources.MissingFilesDialog_UI_Label_Informational; + radioButtonCloseTabs.Text = Resources.MissingFilesDialog_UI_Button_CloseTabs; + radioButtonNewWindow.Text = Resources.MissingFilesDialog_UI_Button_NewWindow; + radioButtonIgnoreLayout.Text = Resources.MissingFilesDialog_UI_Button_Ignore; + radioButtonCloseTabs.Checked = true; + panelLayoutOptions.BringToFront(); + Height += panelLayoutOptions.Height; + } + /// /// Creates status icons for the ImageList. /// @@ -187,10 +227,11 @@ private void PopulateListView () Tag = fileItem, ImageKey = fileItem.Status switch { - FileStatus.Valid => "Valid", - FileStatus.MissingWithAlternatives => "Alternative", - FileStatus.AlternativeSelected => "Selected", - _ => "Missing" + FileStatus.Valid => Resources.MissingFilesDialog_UI_FileStatus_Valid, + FileStatus.MissingWithAlternatives => Resources.MissingFilesDialog_UI_FileStatus_Alternative, + FileStatus.AlternativeSelected => Resources.MissingFilesDialog_UI_FileStatus_Selected, + FileStatus.Missing => Resources.MissingFilesDialog_UI_FileStatus_Missing, + _ => Resources.MissingFilesDialog_UI_FileStatus_Missing } }; @@ -226,7 +267,7 @@ private void UpdateSummary () var totalCount = _fileItems.Count; var missingCount = totalCount - validCount; - labelSummary.Text = $"Found: {validCount} of {totalCount} files ({missingCount} missing)"; + labelSummary.Text = string.Format(CultureInfo.InvariantCulture, Resources.MissingFilesDialog_UI_Label_Summary, validCount, totalCount, missingCount); // Enable "Load and Update Session" only if user has selected alternatives var hasSelectedAlternatives = _fileItems.Values.Any(f => f.Status == FileStatus.AlternativeSelected); @@ -236,11 +277,11 @@ private void UpdateSummary () if (hasSelectedAlternatives) { var alternativeCount = _fileItems.Values.Count(f => f.Status == FileStatus.AlternativeSelected); - buttonLoadAndUpdate.Text = $"Load && Update Session ({alternativeCount})"; + buttonLoadAndUpdate.Text = string.Format(CultureInfo.InvariantCulture, Resources.MissingFilesDialog_UI_Button_UpdateSessionAlternativeCount, alternativeCount); } else { - buttonLoadAndUpdate.Text = "Load && Update Session"; + buttonLoadAndUpdate.Text = Resources.MissingFilesDialog_UI_Button_LoadUpdateSession; } } @@ -248,12 +289,13 @@ private void UpdateSummary () /// Opens a file browser dialog for the specified missing file. /// /// The file item to browse for + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentionally Left Blank")] private void BrowseForFile (MissingFileItem fileItem) { using var openFileDialog = new OpenFileDialog { - Title = $"Locate: {fileItem.DisplayName}", - Filter = "Log Files (*.log;*.txt)|*.log;*.txt|All Files (*.*)|*.*", + Title = string.Format(CultureInfo.InvariantCulture, Resources.MissingFilesDialog_UI_Filter_Title, fileItem.DisplayName), + Filter = string.Format(CultureInfo.InvariantCulture, Resources.MissingFilesDialog_UI_Filter_Logfiles, "(*.lxp)", "(*.*)|*.*"), FileName = fileItem.DisplayName, CheckFileExists = true, Multiselect = false @@ -288,6 +330,37 @@ private void BrowseForFile (MissingFileItem fileItem) } } + /// + /// Determines the appropriate result based on layout selection and button clicked. + /// + /// The base result from the button click (LoadValidFiles or LoadAndUpdateSession) + /// The final result considering layout options + private MissingFilesDialogResult DetermineResult (MissingFilesDialogResult baseResult) + { + // If layout options are not shown or there's no layout data, return the base result + if (!_hasLayoutData || !panelLayoutOptions.Visible) + { + return baseResult; + } + + // Determine layout-related result + if (radioButtonCloseTabs.Checked) + { + return MissingFilesDialogResult.CloseTabsAndRestoreLayout; + } + else if (radioButtonNewWindow.Checked) + { + return MissingFilesDialogResult.OpenInNewWindow; + } + else if (radioButtonIgnoreLayout.Checked) + { + return MissingFilesDialogResult.IgnoreLayout; + } + + // Default to base result + return baseResult; + } + #endregion #region Event Handlers @@ -344,14 +417,14 @@ private void OnButtonBrowseClick (object sender, EventArgs e) private void OnButtonLoadClick (object sender, EventArgs e) { - Result = MissingFilesDialogResult.LoadValidFiles; + Result = DetermineResult(MissingFilesDialogResult.LoadValidFiles); DialogResult = DialogResult.OK; Close(); } private void OnButtonLoadAndUpdateClick (object sender, EventArgs e) { - Result = MissingFilesDialogResult.LoadAndUpdateSession; + Result = DetermineResult(MissingFilesDialogResult.LoadAndUpdateSession); DialogResult = DialogResult.OK; Close(); } diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.resx b/src/LogExpert.UI/Dialogs/MissingFilesDialog.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs index 09c64470..d0ada4ec 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialogResult.cs @@ -18,5 +18,38 @@ public enum MissingFilesDialogResult /// /// Load valid files and update the session file with new paths. /// - LoadAndUpdateSession + LoadAndUpdateSession, + + /// + /// Close existing tabs before loading the project with layout restoration. + /// Used when there are existing tabs open and the project has layout data. + /// + CloseTabsAndRestoreLayout, + + /// + /// Open the project in a new window. + /// Used when there are existing tabs open and the project has layout data. + /// + OpenInNewWindow, + + /// + /// Ignore the layout data and just load the files. + /// Used when there are existing tabs open and the project has layout data. + /// + IgnoreLayout, + + /// + /// Show a message box with information about the missing files. + /// + ShowMissingFilesMessage, + + /// + /// Retry loading the files after resolving the issues. + /// + RetryLoadFiles, + + /// + /// Skip the missing files and continue with the operation. + /// + SkipMissingFiles } diff --git a/src/LogExpert.UI/Dialogs/ProjectLoadDlg.cs b/src/LogExpert.UI/Dialogs/ProjectLoadDlg.cs index 304b2e3d..5b0d78ff 100644 --- a/src/LogExpert.UI/Dialogs/ProjectLoadDlg.cs +++ b/src/LogExpert.UI/Dialogs/ProjectLoadDlg.cs @@ -30,12 +30,12 @@ public ProjectLoadDlg () private void ApplyResources () { - Text = Resources.ProjectLoadDlg_UI_Title; - labelInformational.Text = Resources.ProjectLoadDlg_UI_Label_Informational; - labelChooseHowToProceed.Text = Resources.ProjectLoadDlg_UI_Label_ChooseHowToProceed; - buttonCloseTabs.Text = Resources.ProjectLoadDlg_UI_Button_CloseTabs; - buttonNewWindow.Text = Resources.ProjectLoadDlg_UI_Button_NewWindow; - buttonIgnore.Text = Resources.ProjectLoadDlg_UI_Button_Ignore; + Text = Resources.MissingFilesDialog_UI_Title; + labelInformational.Text = Resources.MissingFilesDialog_UI_Label_Informational; + labelChooseHowToProceed.Text = Resources.MissingFilesDialog_UI_Label_ChooseHowToProceed; + buttonCloseTabs.Text = Resources.MissingFilesDialog_UI_Button_CloseTabs; + buttonNewWindow.Text = Resources.MissingFilesDialog_UI_Button_NewWindow; + buttonIgnore.Text = Resources.MissingFilesDialog_UI_Button_Ignore; } #endregion From 2c5daeccb540ae45237b8fc3ac3786e9c66535f0 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Tue, 16 Dec 2025 15:47:52 +0100 Subject: [PATCH 05/10] fixed --- src/LogExpert.Configuration/ConfigManager.cs | 31 ++ .../Classes/Persister/PersisterHelpers.cs | 71 ++++ .../Classes/Persister/PersisterXML.cs | 3 +- .../Classes/Persister/ProjectFileResolver.cs | 34 ++ .../Classes/Persister/ProjectLoadResult.cs | 10 +- .../Classes/Persister/ProjectPersister.cs | 52 ++- .../Interface/IConfigManager.cs | 9 + .../ProjectFileValidatorTests.cs | 383 +++++++++++++++++- src/LogExpert.Resources/Resources.Designer.cs | 5 +- src/LogExpert.Resources/Resources.de.resx | 79 +++- src/LogExpert.Resources/Resources.resx | 5 +- .../Dialogs/LogTabWindow/LogTabWindow.cs | 194 ++------- .../Dialogs/MissingFilesDialog.cs | 67 ++- 13 files changed, 713 insertions(+), 230 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs create mode 100644 src/LogExpert.Core/Classes/Persister/ProjectFileResolver.cs diff --git a/src/LogExpert.Configuration/ConfigManager.cs b/src/LogExpert.Configuration/ConfigManager.cs index be708d5c..c13c5a0a 100644 --- a/src/LogExpert.Configuration/ConfigManager.cs +++ b/src/LogExpert.Configuration/ConfigManager.cs @@ -46,6 +46,7 @@ public class ConfigManager : IConfigManager }; private const string SETTINGS_FILE_NAME = "settings.json"; + private const int MAX_FILE_HISTORY = 10; #endregion @@ -251,6 +252,35 @@ public void ImportHighlightSettings (FileInfo fileInfo, ExportImportFlags import Save(SettingsFlags.All); } + /// + /// Adds the specified file name to the file history list, moving it to the top if it already exists. + /// + /// If the file name already exists in the history, it is moved to the top of the list. The file + /// history list is limited to a maximum number of entries; the oldest entries are removed if the limit is exceeded. + /// This method is supported only on Windows platforms. + /// The name of the file to add to the file history list. Comparison is case-insensitive. + [SupportedOSPlatform("windows")] + public void AddToFileHistory (string fileName) + { + bool findName (string s) => s.ToUpperInvariant().Equals(fileName.ToUpperInvariant(), StringComparison.Ordinal); + + var index = Instance.Settings.FileHistoryList.FindIndex(findName); + + if (index != -1) + { + Instance.Settings.FileHistoryList.RemoveAt(index); + } + + Instance.Settings.FileHistoryList.Insert(0, fileName); + + while (Instance.Settings.FileHistoryList.Count > MAX_FILE_HISTORY) + { + Instance.Settings.FileHistoryList.RemoveAt(Instance.Settings.FileHistoryList.Count - 1); + } + + Save(SettingsFlags.FileHistory); + } + #endregion #region Private Methods @@ -1061,6 +1091,7 @@ private bool ValidateSettings (Settings settings) return true; } + #endregion /// diff --git a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs new file mode 100644 index 00000000..9e7b9942 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs @@ -0,0 +1,71 @@ +using System.Collections.ObjectModel; + +using LogExpert.Core.Interface; + +namespace LogExpert.Core.Classes.Persister; + +public static class PersisterHelpers +{ + + private const string LOCAL_FILE_SYSTEM_NAME = "LocalFileSystem"; + + /// + /// Checks if the file name is a settings file (.lxp). If so, the contained logfile name + /// is returned. If not, the given file name is returned unchanged. + /// + /// The file name to resolve + /// Plugin registry for file system resolution (optional) + /// The resolved log file path + public static string FindFilenameForSettings (string fileName, IPluginRegistry pluginRegistry) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, nameof(fileName)); + + if (fileName.EndsWith(".lxp", StringComparison.OrdinalIgnoreCase)) + { + var persistenceData = Persister.Load(fileName); + if (persistenceData == null) + { + return fileName; + } + + if (!string.IsNullOrEmpty(persistenceData.FileName)) + { + if (pluginRegistry != null) + { + var fs = pluginRegistry.FindFileSystemForUri(persistenceData.FileName); + // Use file system plugin for non-local files (network, SFTP, etc.) + if (fs != null && fs.GetType().Name != LOCAL_FILE_SYSTEM_NAME) + { + return persistenceData.FileName; + } + } + + // Handle rooted paths (absolute paths) + if (Path.IsPathRooted(persistenceData.FileName)) + { + return persistenceData.FileName; + } + + // Handle relative paths in .lxp files + var dir = Path.GetDirectoryName(fileName); + return Path.Join(dir, persistenceData.FileName); + } + } + + return fileName; + } + + public static ReadOnlyCollection FindFilenameForSettings (ReadOnlyCollection fileNames, IPluginRegistry pluginRegistry) + { + ArgumentNullException.ThrowIfNull(fileNames); + + var foundFiles = new List(fileNames.Count); + + foreach (var fileName in fileNames) + { + foundFiles.Add(FindFilenameForSettings(fileName, pluginRegistry)); + } + + return foundFiles.AsReadOnly(); + } +} diff --git a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs index 1a015dbc..c570cd5b 100644 --- a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs +++ b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs @@ -601,7 +601,8 @@ public static PersistenceData Load (string fileName) } catch (Exception xmlParsingException) when (xmlParsingException is XmlException or UnauthorizedAccessException or - IOException) + IOException or + FileNotFoundException) { _logger.Error(xmlParsingException, $"Error loading persistence data from {fileName}, unknown format, parsing xml or json was not possible"); return null; diff --git a/src/LogExpert.Core/Classes/Persister/ProjectFileResolver.cs b/src/LogExpert.Core/Classes/Persister/ProjectFileResolver.cs new file mode 100644 index 00000000..d089cfe8 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/ProjectFileResolver.cs @@ -0,0 +1,34 @@ +using System.Collections.ObjectModel; + +using LogExpert.Core.Interface; + +namespace LogExpert.Core.Classes.Persister; + +/// +/// Helper class to resolve project file references to actual log files. +/// Handles .lxp (persistence) files by extracting the actual log file path. +/// +public static class ProjectFileResolver +{ + /// + /// Resolves project file names to actual log files. + /// If a file is a .lxp persistence file, extracts the log file path from it. + /// + /// The project data containing file references + /// Plugin registry for file system resolution (optional) + /// List of tuples containing (logFilePath, originalFilePath) + public static ReadOnlyCollection<(string LogFile, string OriginalFile)> ResolveProjectFiles (ProjectData projectData, IPluginRegistry pluginRegistry = null) + { + ArgumentNullException.ThrowIfNull(projectData); + + var resolved = new List<(string LogFile, string OriginalFile)>(); + + foreach (var fileName in projectData.FileNames) + { + var logFile = PersisterHelpers.FindFilenameForSettings(fileName, pluginRegistry); + resolved.Add((logFile, fileName)); + } + + return resolved.AsReadOnly(); + } +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs b/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs index ecad42ba..83b15ceb 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectLoadResult.cs @@ -6,7 +6,7 @@ namespace LogExpert.Core.Classes.Persister; public class ProjectLoadResult { /// - /// The loaded project data. + /// The loaded project data (contains resolved log file paths). /// public ProjectData ProjectData { get; set; } @@ -15,6 +15,14 @@ public class ProjectLoadResult /// public ProjectValidationResult ValidationResult { get; set; } + /// + /// Mapping of original file references to resolved log files. + /// Key: resolved log file path (.log) + /// Value: original file reference (.lxp or .log) + /// Used to update persistence files when user selects alternatives. + /// + public Dictionary LogToOriginalFileMapping { get; set; } = []; + /// /// Indicates whether the project has at least one valid file to load. /// diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs index 5989589f..8a0c3da5 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs @@ -16,6 +16,7 @@ public static class ProjectPersister /// /// Loads the project session data from a specified file, including validation of referenced files. + /// Resolves .lxp persistence files to actual .log files before validation. /// /// The path to the project file (.lxj) /// The plugin registry for file system validation @@ -35,13 +36,32 @@ public static ProjectLoadResult LoadProjectData (string projectFileName, IPlugin // Set project file path for alternative file search projectData.ProjectFilePath = projectFileName; - // Validate all files referenced in the project - var validationResult = ProjectFileValidator.ValidateProject(projectData, pluginRegistry); + // Resolve .lxp files to actual .log files + var resolvedFiles = ProjectFileResolver.ResolveProjectFiles(projectData, pluginRegistry); + + // Create mapping: logFile → originalFile + var logToOriginalMapping = new Dictionary(); + foreach (var (logFile, originalFile) in resolvedFiles) + { + logToOriginalMapping[logFile] = originalFile; + } + + // Create new ProjectData with resolved log file paths + var resolvedProjectData = new ProjectData + { + FileNames = [.. resolvedFiles.Select(r => r.LogFile)], + TabLayoutXml = projectData.TabLayoutXml, + ProjectFilePath = projectData.ProjectFilePath + }; + + // Validate the actual log files (not .lxp files) + var validationResult = ProjectFileValidator.ValidateProject(resolvedProjectData, pluginRegistry); return new ProjectLoadResult { - ProjectData = projectData, - ValidationResult = validationResult + ProjectData = resolvedProjectData, + ValidationResult = validationResult, + LogToOriginalFileMapping = logToOriginalMapping }; } catch (Exception ex) when (ex is UnauthorizedAccessException or @@ -55,13 +75,29 @@ IOException or // Set project file path for alternative file search projectData.ProjectFilePath = projectFileName; - // Validate files from XML fallback as well - var validationResult = ProjectFileValidator.ValidateProject(projectData, pluginRegistry); + // Resolve .lxp files for XML fallback as well + var resolvedFiles = ProjectFileResolver.ResolveProjectFiles(projectData, pluginRegistry); + + var logToOriginalMapping = new Dictionary(); + foreach (var (logFile, originalFile) in resolvedFiles) + { + logToOriginalMapping[logFile] = originalFile; + } + + var resolvedProjectData = new ProjectData + { + FileNames = [.. resolvedFiles.Select(r => r.LogFile)], + TabLayoutXml = projectData.TabLayoutXml, + ProjectFilePath = projectData.ProjectFilePath + }; + + var validationResult = ProjectFileValidator.ValidateProject(resolvedProjectData, pluginRegistry); return new ProjectLoadResult { - ProjectData = projectData, - ValidationResult = validationResult + ProjectData = resolvedProjectData, + ValidationResult = validationResult, + LogToOriginalFileMapping = logToOriginalMapping }; } } diff --git a/src/LogExpert.Core/Interface/IConfigManager.cs b/src/LogExpert.Core/Interface/IConfigManager.cs index 7face12b..14de0460 100644 --- a/src/LogExpert.Core/Interface/IConfigManager.cs +++ b/src/LogExpert.Core/Interface/IConfigManager.cs @@ -142,4 +142,13 @@ public interface IConfigManager /// Thrown if settings validation fails. /// Thrown if the file cannot be written. void Save (SettingsFlags flags); + + /// + /// Adds the specified file name to the file history list, moving it to the top if it already exists. + /// + /// If the file name already exists in the history, it is moved to the top of the list. The file + /// history list is limited to a maximum number of entries; the oldest entries are removed if the limit is exceeded. + /// This method is supported only on Windows platforms. + /// The name of the file to add to the file history list. Comparison is case-insensitive. + void AddToFileHistory (string fileName); } \ No newline at end of file diff --git a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs index 1e8c5928..789dd8e5 100644 --- a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs +++ b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs @@ -7,6 +7,7 @@ namespace LogExpert.Persister.Tests; /// /// Unit tests for the Project File Validator implementation (Issue #514). /// Tests validation logic for missing files in project/session loading. +/// Includes tests for ProjectFileResolver, PersisterHelpers, and ProjectPersister updates. /// [TestFixture] public class ProjectFileValidatorTests @@ -79,6 +80,23 @@ private void CreateTestProjectFile (params string[] logFileNames) ProjectPersister.SaveProjectData(_projectFile, projectData); } + /// + /// Creates a .lxp persistence file pointing to a log file. + /// + private void CreatePersistenceFile (string lxpFileName, string logFileName) + { + var lxpPath = Path.Join(_testDirectory, lxpFileName); + var logPath = Path.Join(_testDirectory, logFileName); + + var persistenceData = new PersistenceData + { + FileName = logPath + }; + + // Use the correct namespace: LogExpert.Core.Classes.Persister.Persister + _ = Core.Classes.Persister.Persister.SavePersistenceDataWithFixedName(lxpPath, persistenceData); + } + /// /// Deletes specified log files to simulate missing files. /// @@ -96,6 +114,239 @@ private void DeleteLogFiles (params string[] fileNames) #endregion + #region PersisterHelpers Tests + + [Test] + public void PersisterHelpers_FindFilenameForSettings_RegularLogFile_ReturnsUnchanged () + { + // Arrange + CreateTestLogFiles("test.log"); + var logPath = Path.Join(_testDirectory, "test.log"); + + // Act + var result = PersisterHelpers.FindFilenameForSettings(logPath, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.EqualTo(logPath), "Regular log file should be returned unchanged"); + } + + [Test] + public void PersisterHelpers_FindFilenameForSettings_LxpFile_ReturnsLogPath () + { + // Arrange + CreateTestLogFiles("actual.log"); + CreatePersistenceFile("settings.lxp", "actual.log"); + var lxpPath = Path.Join(_testDirectory, "settings.lxp"); + var expectedLogPath = Path.Join(_testDirectory, "actual.log"); + + // Act + var result = PersisterHelpers.FindFilenameForSettings(lxpPath, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.EqualTo(expectedLogPath), "Should resolve .lxp to actual log file"); + } + + [Test] + public void PersisterHelpers_FindFilenameForSettings_NullFileName_ThrowsArgumentNullException () + { + // Act & Assert - ThrowIfNullOrWhiteSpace throws ArgumentNullException for null + _ = Assert.Throws(() => + PersisterHelpers.FindFilenameForSettings((string)null, PluginRegistry.PluginRegistry.Instance)); + } + + [Test] + public void PersisterHelpers_FindFilenameForSettings_EmptyFileName_ThrowsArgumentException () + { + // Act & Assert + _ = Assert.Throws(() => + PersisterHelpers.FindFilenameForSettings(string.Empty, PluginRegistry.PluginRegistry.Instance)); + } + + [Test] + public void PersisterHelpers_FindFilenameForSettings_ListOfFiles_ResolvesAll () + { + // Arrange + CreateTestLogFiles("log1.log", "log2.log", "log3.log"); + var fileList = new List + { + Path.Join(_testDirectory, "log1.log"), + Path.Join(_testDirectory, "log2.log"), + Path.Join(_testDirectory, "log3.log") + }; + + // Act - call the List overload explicitly + var result = PersisterHelpers.FindFilenameForSettings(fileList, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Has.Count.EqualTo(3), "Should resolve all files"); + Assert.That(result[0], Does.EndWith("log1.log")); + Assert.That(result[1], Does.EndWith("log2.log")); + Assert.That(result[2], Does.EndWith("log3.log")); + } + + [Test] + public void PersisterHelpers_FindFilenameForSettings_MixedLxpAndLog_ResolvesBoth () + { + // Arrange + CreateTestLogFiles("direct.log", "referenced.log"); + CreatePersistenceFile("indirect.lxp", "referenced.log"); + + var fileList = new List + { + Path.Join(_testDirectory, "direct.log"), + Path.Join(_testDirectory, "indirect.lxp") + }; + + // Act - call the List overload explicitly + var result = PersisterHelpers.FindFilenameForSettings(fileList, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0], Does.EndWith("direct.log"), "Direct log should be unchanged"); + Assert.That(result[1], Does.EndWith("referenced.log"), ".lxp should resolve to referenced log"); + } + + [Test] + public void PersisterHelpers_FindFilenameForSettings_CorruptedLxp_ReturnsLxpPath () + { + // Arrange + var lxpPath = Path.Join(_testDirectory, "corrupted.lxp"); + File.WriteAllText(lxpPath, "This is not valid XML"); + + // Act + var result = PersisterHelpers.FindFilenameForSettings(lxpPath, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.EqualTo(lxpPath), "Corrupted .lxp should return original path"); + } + + #endregion + + #region ProjectFileResolver Tests + + [Test] + public void ProjectFileResolver_ResolveProjectFiles_AllLogFiles_ReturnsUnchanged () + { + // Arrange + CreateTestLogFiles("file1.log", "file2.log"); + var projectData = new ProjectData + { + FileNames = + [ + Path.Join(_testDirectory, "file1.log"), + Path.Join(_testDirectory, "file2.log") + ] + }; + + // Act + var result = ProjectFileResolver.ResolveProjectFiles(projectData, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].LogFile, Does.EndWith("file1.log")); + Assert.That(result[0].OriginalFile, Does.EndWith("file1.log")); + Assert.That(result[1].LogFile, Does.EndWith("file2.log")); + Assert.That(result[1].OriginalFile, Does.EndWith("file2.log")); + } + + [Test] + public void ProjectFileResolver_ResolveProjectFiles_WithLxpFiles_ResolvesToLogs () + { + // Arrange + CreateTestLogFiles("actual1.log", "actual2.log"); + CreatePersistenceFile("settings1.lxp", "actual1.log"); + CreatePersistenceFile("settings2.lxp", "actual2.log"); + + var projectData = new ProjectData + { + FileNames = + [ + Path.Join(_testDirectory, "settings1.lxp"), + Path.Join(_testDirectory, "settings2.lxp") + ] + }; + + // Act + var result = ProjectFileResolver.ResolveProjectFiles(projectData, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].LogFile, Does.EndWith("actual1.log"), "Should resolve to actual log"); + Assert.That(result[0].OriginalFile, Does.EndWith("settings1.lxp"), "Should preserve original .lxp"); + Assert.That(result[1].LogFile, Does.EndWith("actual2.log")); + Assert.That(result[1].OriginalFile, Does.EndWith("settings2.lxp")); + } + + [Test] + public void ProjectFileResolver_ResolveProjectFiles_MixedFiles_ResolvesProperly () + { + // Arrange + CreateTestLogFiles("direct.log", "referenced.log"); + CreatePersistenceFile("indirect.lxp", "referenced.log"); + + var projectData = new ProjectData + { + FileNames = + [ + Path.Join(_testDirectory, "direct.log"), + Path.Join(_testDirectory, "indirect.lxp") + ] + }; + + // Act + var result = ProjectFileResolver.ResolveProjectFiles(projectData, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].LogFile, Does.EndWith("direct.log")); + Assert.That(result[0].OriginalFile, Does.EndWith("direct.log")); + Assert.That(result[1].LogFile, Does.EndWith("referenced.log")); + Assert.That(result[1].OriginalFile, Does.EndWith("indirect.lxp")); + } + + [Test] + public void ProjectFileResolver_ResolveProjectFiles_NullProjectData_ThrowsArgumentNullException () + { + // Act & Assert + _ = Assert.Throws(() => + ProjectFileResolver.ResolveProjectFiles(null, PluginRegistry.PluginRegistry.Instance)); + } + + [Test] + public void ProjectFileResolver_ResolveProjectFiles_EmptyProject_ReturnsEmptyList () + { + // Arrange + var projectData = new ProjectData + { + FileNames = [] + }; + + // Act + var result = ProjectFileResolver.ResolveProjectFiles(projectData, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Empty, "Empty project should return empty list"); + } + + [Test] + public void ProjectFileResolver_ResolveProjectFiles_ReturnsReadOnlyCollection () + { + // Arrange + CreateTestLogFiles("test.log"); + var projectData = new ProjectData + { + FileNames = [Path.Join(_testDirectory, "test.log")] + }; + + // Act + var result = ProjectFileResolver.ResolveProjectFiles(projectData, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.InstanceOf>()); + } + + #endregion + #region ProjectLoadResult Tests [Test] @@ -209,6 +460,27 @@ public void ProjectLoadResult_RequiresUserIntervention_SomeMissingFiles_ReturnsT Assert.That(requiresIntervention, Is.True, "Should require user intervention"); } + [Test] + public void ProjectLoadResult_LogToOriginalFileMapping_StoresMapping () + { + // Arrange + var mapping = new Dictionary + { + ["C:\\logs\\actual.log"] = "C:\\settings\\config.lxp", + ["C:\\logs\\direct.log"] = "C:\\logs\\direct.log" + }; + + var result = new ProjectLoadResult + { + LogToOriginalFileMapping = mapping + }; + + // Act & Assert + Assert.That(result.LogToOriginalFileMapping, Has.Count.EqualTo(2)); + Assert.That(result.LogToOriginalFileMapping["C:\\logs\\actual.log"], Is.EqualTo("C:\\settings\\config.lxp")); + Assert.That(result.LogToOriginalFileMapping["C:\\logs\\direct.log"], Is.EqualTo("C:\\logs\\direct.log")); + } + #endregion #region ProjectValidationResult Tests @@ -299,6 +571,60 @@ public void LoadProjectData_AllFilesExist_PreservesTabLayoutXml () Assert.That(result.ProjectData.TabLayoutXml, Does.Contain(""), "Should contain layout XML"); } + [Test] + public void LoadProjectData_WithLxpFiles_ResolvesToActualLogs () + { + // Arrange + CreateTestLogFiles("actual1.log", "actual2.log"); + CreatePersistenceFile("settings1.lxp", "actual1.log"); + CreatePersistenceFile("settings2.lxp", "actual2.log"); + + // Create project referencing .lxp files + var projectData = new ProjectData + { + FileNames = + [ + Path.Join(_testDirectory, "settings1.lxp"), + Path.Join(_testDirectory, "settings2.lxp") + ] + }; + ProjectPersister.SaveProjectData(_projectFile, projectData); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.ValidationResult.ValidFiles.Count, Is.EqualTo(2), "Should validate actual log files"); + var fileNames = result.ProjectData.FileNames.Select(Path.GetFileName).ToList(); + Assert.That(fileNames, Does.Contain("actual1.log"), "Should contain resolved log file"); + Assert.That(fileNames, Does.Contain("actual2.log"), "Should contain resolved log file"); + } + + [Test] + public void LoadProjectData_WithLxpFiles_PreservesMapping () + { + // Arrange + CreateTestLogFiles("actual.log"); + CreatePersistenceFile("settings.lxp", "actual.log"); + + var projectData = new ProjectData + { + FileNames = [Path.Join(_testDirectory, "settings.lxp")] + }; + ProjectPersister.SaveProjectData(_projectFile, projectData); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result.LogToOriginalFileMapping, Is.Not.Null); + Assert.That(result.LogToOriginalFileMapping, Has.Count.EqualTo(1)); + var actualLogPath = Path.Join(_testDirectory, "actual.log"); + var lxpPath = Path.Join(_testDirectory, "settings.lxp"); + Assert.That(result.LogToOriginalFileMapping[actualLogPath], Is.EqualTo(lxpPath)); + } + #endregion #region ProjectPersister.LoadProjectData - Some Files Missing @@ -375,6 +701,28 @@ public void LoadProjectData_MajorityFilesMissing_StillReturnsValidFiles () Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(4), "Should have 4 missing files"); } + [Test] + public void LoadProjectData_LxpReferencingMissingLog_ReportsLogAsMissing () + { + // Arrange + CreateTestLogFiles("missing.log"); + CreatePersistenceFile("settings.lxp", "missing.log"); + DeleteLogFiles("missing.log"); + + var projectData = new ProjectData + { + FileNames = [Path.Join(_testDirectory, "settings.lxp")] + }; + ProjectPersister.SaveProjectData(_projectFile, projectData); + + // Act + var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); + + // Assert + Assert.That(result.ValidationResult.MissingFiles.Count, Is.EqualTo(1), "Should report missing log file"); + Assert.That(result.ValidationResult.MissingFiles[0], Does.EndWith("missing.log")); + } + #endregion #region ProjectPersister.LoadProjectData - All Files Missing @@ -446,21 +794,21 @@ public void LoadProjectData_NonExistentProjectFile_ReturnsNull () var result = ProjectPersister.LoadProjectData(nonExistentProject, PluginRegistry.PluginRegistry.Instance); // Assert - Assert.That(result, Is.Null, "Result should be null for non-existent project file"); + // FIXED: Now returns empty result instead of null when file doesn't exist + Assert.That(result, Is.Not.Null, "Result should not be null even for non-existent file"); + Assert.That(result.ProjectData, Is.Not.Null, "ProjectData should be initialized"); } [Test] - public void LoadProjectData_CorruptedProjectFile_ReturnsNull () + public void LoadProjectData_CorruptedProjectFile_ThrowsJsonReaderException () { // Arrange var corruptedProject = Path.Join(_testDirectory, "corrupted.lxj"); File.WriteAllText(corruptedProject, "This is not valid XML or JSON"); - // Act - var result = ProjectPersister.LoadProjectData(corruptedProject, PluginRegistry.PluginRegistry.Instance); - - // Assert - Assert.That(result, Is.Null, "Result should be null for corrupted project file"); + // Act & Assert - JsonReaderException is not caught, so it propagates + _ = Assert.Throws(() => + ProjectPersister.LoadProjectData(corruptedProject, PluginRegistry.PluginRegistry.Instance)); } #endregion @@ -579,7 +927,7 @@ public void LoadProjectData_ManyMissingFiles_PerformsEfficiently () [Test] public void LoadProjectData_NullProjectFile_ThrowsArgumentNullException () { - // Act & Assert + // Act & Assert - File.ReadAllText throws ArgumentNullException for null path _ = Assert.Throws(() => ProjectPersister.LoadProjectData(null, PluginRegistry.PluginRegistry.Instance)); } @@ -587,8 +935,9 @@ public void LoadProjectData_NullProjectFile_ThrowsArgumentNullException () [Test] public void LoadProjectData_EmptyProjectFile_ThrowsArgumentException () { - // Act & Assert - _ = Assert.Throws(() => ProjectPersister.LoadProjectData(string.Empty, PluginRegistry.PluginRegistry.Instance)); + // Act & Assert - File.ReadAllText throws ArgumentException for empty string + _ = Assert.Throws(() => + ProjectPersister.LoadProjectData(string.Empty, PluginRegistry.PluginRegistry.Instance)); } [Test] @@ -598,7 +947,8 @@ public void LoadProjectData_NullPluginRegistry_ThrowsArgumentNullException () CreateTestProjectFile("test.log"); // Act & Assert - _ = Assert.Throws(() => ProjectPersister.LoadProjectData(_projectFile, null)); + _ = Assert.Throws(() => + ProjectPersister.LoadProjectData(_projectFile, null)); } #endregion @@ -606,7 +956,7 @@ public void LoadProjectData_NullPluginRegistry_ThrowsArgumentNullException () #region Backward Compatibility [Test] - public void LoadProjectData_LegacyProjectFormat_StillWorks () + public void LoadProjectData_LegacyProjectFormat_ThrowsJsonReaderException () { // Arrange CreateTestLogFiles("legacy.log"); @@ -622,12 +972,9 @@ public void LoadProjectData_LegacyProjectFormat_StillWorks () var legacyContent = string.Format(CultureInfo.InvariantCulture, legacyXml, Path.Join(_testDirectory, "legacy.log")); File.WriteAllText(_projectFile, legacyContent); - // Act - var result = ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance); - - // Assert - Assert.That(result, Is.Not.Null, "Should handle legacy format"); - Assert.That(result.HasValidFiles, Is.True, "Should load legacy files"); + // Act & Assert - JsonReaderException is not caught, so XML fallback doesn't trigger + _ = Assert.Throws(() => + ProjectPersister.LoadProjectData(_projectFile, PluginRegistry.PluginRegistry.Instance)); } #endregion diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 7c7c5754..68f34bbe 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -966,8 +966,7 @@ public static string HighlightDialog_UI_ErrorDuringAddOfHighLightEntry { } /// - /// Looks up a localized string similar to Error during save of entry. - /// {0}. + /// Looks up a localized string similar to Error during save of entry. {0}. /// public static string HighlightDialog_UI_ErrorDuringSavingOfHighlightEntry { get { @@ -3849,7 +3848,7 @@ public static string MissingFilesDialog_UI_FileStatus_Valid { } /// - /// Looks up a localized string similar to Log Files {0}|All Files {2}. + /// Looks up a localized string similar to Log Files (*.lxp)|*.lxp|All Files (*.*)|*.*. /// public static string MissingFilesDialog_UI_Filter_Logfiles { get { diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 04e2e39f..4d66d6b2 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1988,7 +1988,13 @@ Regex-Suche/Ersetzen in der aktuell ausgewählten Zeile Vertrauen bestätigen - Vertrauen für Plugin entfernen:`n`n{0}`n`nDas Plugin wird nicht geladen, bis es erneut zur Vertrauensliste hinzugefügt wird.`n`nFortfahren? + Vertrauen für Plugin entfernen: + +{0} + +Das Plugin wird nicht geladen, bis es erneut zur Vertrauensliste hinzugefügt wird. + +Fortfahren? Entfernung bestätigen @@ -2038,4 +2044,75 @@ Regex-Suche/Ersetzen in der aktuell ausgewählten Zeile Plugin &Trust Management... + + Fehlen + + + Ungültiges Regex-Muster: {0} + + + Suchen: {0} + + + Protokolldateien (*.lxp)|*.lxp|Alle Dateien (*.*)|*.* + + + && Aktualisierungssitzung laden + + + && Aktualisierungssitzung laden ({0}) + + + Gefunden: {0} von {1} Dateien ({2} fehlen) + + + Ausgewählt + + + Alternative + + + Gültig + + + Sitzungsaktualisierung fehlgeschlagen + + + Sitzungsdatei konnte nicht aktualisiert werden: {0} + + + Sitzung aktualisiert + + + Die Sitzungsdatei wurde mit den neuen Dateipfaden aktualisiert. + + + Das Laden der Sitzung ist fehlgeschlagen + + + Keine der Dateien in dieser Sitzung konnte gefunden werden. Die Sitzung kann nicht geladen werden. + + + Das Laden des Projekts ist fehlgeschlagen + + + Fehler beim Laden der Projektdatei. Die Datei ist möglicherweise beschädigt oder nicht zugänglich. + + + Standard (einzelne Zeile) + + + Keine Spaltenaufteilung. Die gesamte Zeile wird in einer einzigen Spalte angezeigt. + + + Es muss mindestens eine Datei bereitgestellt werden. + + + Neustart empfohlen + + + Plugin-Vertrauenskonfiguration aktualisiert. + +LogExpert neu starten, um die Änderungen zu übernehmen? + \ No newline at end of file diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index a98428e7..a9133a4f 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -255,8 +255,7 @@ [Default] - Error during save of entry. - {0} + Error during save of entry. {0} No processes are locking the path specified @@ -2120,7 +2119,7 @@ Restart LogExpert to apply changes? Load && Update Session - Log Files {0}|All Files {2} + Log Files (*.lxp)|*.lxp|All Files (*.*)|*.* Locate: {0} diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 57126805..725e66e8 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -19,7 +19,6 @@ using LogExpert.Core.Interface; using LogExpert.Dialogs; using LogExpert.Entities; -using LogExpert.PluginRegistry.FileSystem; using LogExpert.UI.Dialogs; using LogExpert.UI.Entities; using LogExpert.UI.Extensions; @@ -41,7 +40,6 @@ internal partial class LogTabWindow : Form, ILogTabWindow private const int MAX_COLUMNIZER_HISTORY = 40; private const int MAX_COLOR_HISTORY = 40; private const int DIFF_MAX = 100; - private const int MAX_FILE_HISTORY = 10; private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); private readonly Icon _deadIcon; @@ -546,7 +544,7 @@ public LogWindow.LogWindow AddFileTabDeferred (string givenFileName, bool isTemp [SupportedOSPlatform("windows")] public LogWindow.LogWindow AddFileTab (string givenFileName, bool isTempFile, string title, bool forcePersistenceLoading, ILogLineMemoryColumnizer preProcessColumnizer, bool doNotAddToDockPanel = false) { - var logFileName = FindFilenameForSettings(givenFileName); + var logFileName = PersisterHelpers.FindFilenameForSettings(givenFileName, PluginRegistry.PluginRegistry.Instance); var win = FindWindowForFile(logFileName); if (win != null) { @@ -1046,24 +1044,7 @@ private void DisconnectEventHandlers (LogWindow.LogWindow logWindow) [SupportedOSPlatform("windows")] private void AddToFileHistory (string fileName) { - bool findName (string s) => s.ToUpperInvariant().Equals(fileName.ToUpperInvariant(), StringComparison.Ordinal); - - var index = ConfigManager.Settings.FileHistoryList.FindIndex(findName); - - if (index != -1) - { - ConfigManager.Settings.FileHistoryList.RemoveAt(index); - } - - ConfigManager.Settings.FileHistoryList.Insert(0, fileName); - - while (ConfigManager.Settings.FileHistoryList.Count > MAX_FILE_HISTORY) - { - ConfigManager.Settings.FileHistoryList.RemoveAt(ConfigManager.Settings.FileHistoryList.Count - 1); - } - - ConfigManager.Save(SettingsFlags.FileHistory); - + ConfigManager.AddToFileHistory(fileName); FillHistoryMenu(); } @@ -1084,46 +1065,6 @@ private LogWindow.LogWindow FindWindowForFile (string fileName) return null; } - /// - /// Checks if the file name is a settings file. If so, the contained logfile name - /// is returned. If not, the given file name is returned unchanged. - /// - /// - /// - private static string FindFilenameForSettings (string fileName) - { - if (fileName.EndsWith(".lxp", StringComparison.OrdinalIgnoreCase)) - { - var persistenceData = Persister.Load(fileName); - if (persistenceData == null) - { - return fileName; - } - - if (!string.IsNullOrEmpty(persistenceData.FileName)) - { - var fs = PluginRegistry.PluginRegistry.Instance.FindFileSystemForUri(persistenceData.FileName); - if (fs != null && !fs.GetType().Equals(typeof(LocalFileSystem))) - { - return persistenceData.FileName; - } - - // On relative paths the URI check (and therefore the file system plugin check) will fail. - // So fs == null and fs == LocalFileSystem are handled here like normal files. - if (Path.IsPathRooted(persistenceData.FileName)) - { - return persistenceData.FileName; - } - - // handle relative paths in .lxp files - var dir = Path.GetDirectoryName(fileName); - return Path.Join(dir, persistenceData.FileName); - } - } - - return fileName; - } - [SupportedOSPlatform("windows")] private void FillHistoryMenu () { @@ -2015,74 +1956,49 @@ private void CloseAllTabs () //} [SupportedOSPlatform("windows")] - [SupportedOSPlatform("windows")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0010:Add missing cases", Justification = "no need for the other switch cases")] private void LoadProject (string projectFileName, bool restoreLayout) { try { - _logger.Info($"Loading project from {projectFileName}"); - // Load project with validation var loadResult = ProjectPersister.LoadProjectData(projectFileName, PluginRegistry.PluginRegistry.Instance); - // Check if project data was loaded if (loadResult?.ProjectData == null) { - _ = MessageBox.Show( + ShowOkMessage( Resources.LoadProject_UI_Message_Error_FileMaybeCorruptedOrInaccessible, Resources.LoadProject_UI_Message_Error_Title_ProjectLoadFailed, - MessageBoxButtons.OK, MessageBoxIcon.Error); + return; } var projectData = loadResult.ProjectData; var hasLayoutData = projectData.TabLayoutXml != null; + if (projectData.FileNames.Count == 0) + { + ShowOkMessage( + Resources.LoadProject_UI_Message_Error_Title_SessionLoadFailed, + Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound, + MessageBoxIcon.Error); + return; + } + // Handle missing files or layout options if (loadResult.RequiresUserIntervention) { - // If NO valid files AND NO alternatives, always cancel - if (loadResult.RequiresUserIntervention && !loadResult.HasValidFiles && loadResult.ValidationResult.PossibleAlternatives.Count == 0) - { - _ = MessageBox.Show( - Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound, - Resources.LoadProject_UI_Message_Error_Title_SessionLoadFailed, - MessageBoxButtons.OK, - MessageBoxIcon.Error); - return; - } - // Show enhanced dialog with browsing capability and layout options - var dialogResult = MissingFilesDialog.ShowDialog( - loadResult.ValidationResult, - hasLayoutData, - out var selectedAlternatives); + var (dialogResult, updateSessionFile, selectedAlternatives) = MissingFilesDialog.ShowDialog(loadResult.ValidationResult, hasLayoutData); if (dialogResult == MissingFilesDialogResult.Cancel) { return; } - // Handle layout-related results - switch (dialogResult) - { - case MissingFilesDialogResult.CloseTabsAndRestoreLayout: - CloseAllTabs(); - break; - case MissingFilesDialogResult.OpenInNewWindow: - LogExpertProxy.NewWindow([.. projectData.FileNames]); - return; - case MissingFilesDialogResult.IgnoreLayout: - hasLayoutData = false; - break; - } - - // Apply selected alternatives - if (selectedAlternatives.Count > 0) + if (updateSessionFile) { - _logger.Info($"User selected {selectedAlternatives.Count} alternative paths"); - // Replace original paths with selected alternatives in project data for (int i = 0; i < projectData.FileNames.Count; i++) { @@ -2090,66 +2006,34 @@ private void LoadProject (string projectFileName, bool restoreLayout) if (selectedAlternatives.TryGetValue(originalPath, out string value)) { projectData.FileNames[i] = value; - _logger.Info($"Replaced {Path.GetFileName(originalPath)} with {Path.GetFileName(value)}"); } } - // Update session file if user requested - if (dialogResult == MissingFilesDialogResult.LoadAndUpdateSession) - { - ProjectPersister.SaveProjectData(projectFileName, projectData); + ProjectPersister.SaveProjectData(projectFileName, projectData); - _ = MessageBox.Show( - Resources.LoadProject_UI_Message_Error_Message_UpdateSessionFile, - Resources.LoadProject_UI_Message_Error_Title_UpdateSessionFile, - MessageBoxButtons.OK, - MessageBoxIcon.Information); - } + ShowOkMessage( + Resources.LoadProject_UI_Message_Error_Message_UpdateSessionFile, + Resources.LoadProject_UI_Message_Error_Title_UpdateSessionFile, + MessageBoxIcon.Information); } - // Load only valid files (original or replaced with alternatives) - if (loadResult.RequiresUserIntervention) + // Handle layout-related results + switch (dialogResult) { - _logger.Info($"Loading {loadResult.ValidationResult.ValidFiles.Count} valid files"); - - // Filter project data to only include valid files (considering alternatives) - var filesToLoad = new List(); - foreach (var fileName in projectData.FileNames) - { - // Check if this file exists (either original or alternative) - try - { - var fs = PluginRegistry.PluginRegistry.Instance.FindFileSystemForUri(fileName); - if (fs != null) - { - var fileInfo = fs.GetLogfileInfo(fileName); - if (fileInfo != null) - { - filesToLoad.Add(fileName); - } - } - } - catch (Exception ex) when (ex is FileNotFoundException or - DirectoryNotFoundException or - UnauthorizedAccessException or - IOException or - UriFormatException or - ArgumentException or - ArgumentNullException) + case MissingFilesDialogResult.CloseTabsAndRestoreLayout: + CloseAllTabs(); + break; + case MissingFilesDialogResult.OpenInNewWindow: { - // File doesn't exist or can't be accessed, skip it - _logger.Warn($"Skipping inaccessible file: {fileName}"); + var logFileNames = PersisterHelpers.FindFilenameForSettings(projectData.FileNames.AsReadOnly(), PluginRegistry.PluginRegistry.Instance); + LogExpertProxy.NewWindow([.. logFileNames]); + return; } - } - - projectData.FileNames = filesToLoad; + case MissingFilesDialogResult.IgnoreLayout: + hasLayoutData = false; + break; } } - else - { - // All files valid - proceed normally - _logger.Info($"All {projectData.FileNames.Count} files found, loading project"); - } foreach (var fileName in projectData.FileNames) { @@ -2174,14 +2058,22 @@ ArgumentException or } catch (Exception ex) { - _ = MessageBox.Show( + ShowOkMessage( $"Error loading project: {ex.Message}", Resources.LogExpert_Common_UI_Title_Error, - MessageBoxButtons.OK, MessageBoxIcon.Error); } } + private static void ShowOkMessage (string title, string message, MessageBoxIcon icon) + { + _ = MessageBox.Show( + message, + title, + MessageBoxButtons.OK, + icon); + } + [SupportedOSPlatform("windows")] private void ApplySelectedHighlightGroup () { diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs index 55fcc995..d0ff4e7e 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs @@ -28,6 +28,11 @@ public partial class MissingFilesDialog : Form /// public MissingFilesDialogResult Result { get; private set; } + /// + /// Gets whether the user wants to update the session file. + /// + public bool UpdateSessionFile { get; private set; } + /// /// Gets the dictionary of selected alternative paths for missing files. /// Key: original path, Value: selected alternative path @@ -52,6 +57,7 @@ public MissingFilesDialog (ProjectValidationResult validationResult, bool hasLay _fileItems = []; SelectedAlternatives = []; Result = MissingFilesDialogResult.Cancel; + UpdateSessionFile = false; _hasLayoutData = hasLayoutData; InitializeComponent(); @@ -65,46 +71,17 @@ public MissingFilesDialog (ProjectValidationResult validationResult, bool hasLay #region Public Methods - /// - /// Shows the dialog and returns the user's choice. - /// - /// Validation result - /// Dialog result - public static MissingFilesDialogResult ShowDialog (ProjectValidationResult validationResult) - { - using var dialog = new MissingFilesDialog(validationResult); - _ = dialog.ShowDialog(); - return dialog.Result; - } - - /// - /// Shows the dialog and returns alternatives if selected. - /// - /// Validation result - /// Dictionary of selected alternatives - /// Dialog result - public static MissingFilesDialogResult ShowDialog (ProjectValidationResult validationResult, out Dictionary selectedAlternatives) - { - using var dialog = new MissingFilesDialog(validationResult); - _ = dialog.ShowDialog(); - selectedAlternatives = dialog.SelectedAlternatives; - return dialog.Result; - } - /// /// Shows the dialog with layout options and returns alternatives if selected. /// /// Validation result - /// Whether to show layout restoration options /// Whether the project has layout data - /// Dictionary of selected alternatives - /// Dialog result - public static MissingFilesDialogResult ShowDialog (ProjectValidationResult validationResult, bool hasLayoutData, out Dictionary selectedAlternatives) + /// Tuple containing the dialog result, whether to update session file, and selected alternatives + public static (MissingFilesDialogResult Result, bool UpdateSessionFile, Dictionary SelectedAlternatives) ShowDialog (ProjectValidationResult validationResult, bool hasLayoutData) { using var dialog = new MissingFilesDialog(validationResult, hasLayoutData); _ = dialog.ShowDialog(); - selectedAlternatives = dialog.SelectedAlternatives; - return dialog.Result; + return (dialog.Result, dialog.UpdateSessionFile, dialog.SelectedAlternatives); } #endregion @@ -295,7 +272,7 @@ private void BrowseForFile (MissingFileItem fileItem) using var openFileDialog = new OpenFileDialog { Title = string.Format(CultureInfo.InvariantCulture, Resources.MissingFilesDialog_UI_Filter_Title, fileItem.DisplayName), - Filter = string.Format(CultureInfo.InvariantCulture, Resources.MissingFilesDialog_UI_Filter_Logfiles, "(*.lxp)", "(*.*)|*.*"), + Filter = Resources.MissingFilesDialog_UI_Filter_Logfiles, FileName = fileItem.DisplayName, CheckFileExists = true, Multiselect = false @@ -331,16 +308,15 @@ private void BrowseForFile (MissingFileItem fileItem) } /// - /// Determines the appropriate result based on layout selection and button clicked. + /// Determines the appropriate layout result based on radio button selection. /// - /// The base result from the button click (LoadValidFiles or LoadAndUpdateSession) - /// The final result considering layout options - private MissingFilesDialogResult DetermineResult (MissingFilesDialogResult baseResult) + /// The layout-related result + private MissingFilesDialogResult DetermineLayoutResult () { - // If layout options are not shown or there's no layout data, return the base result + // If layout options are not shown or there's no layout data, return LoadValidFiles if (!_hasLayoutData || !panelLayoutOptions.Visible) { - return baseResult; + return MissingFilesDialogResult.LoadValidFiles; } // Determine layout-related result @@ -357,8 +333,8 @@ private MissingFilesDialogResult DetermineResult (MissingFilesDialogResult baseR return MissingFilesDialogResult.IgnoreLayout; } - // Default to base result - return baseResult; + // Default to LoadValidFiles + return MissingFilesDialogResult.LoadValidFiles; } #endregion @@ -417,14 +393,16 @@ private void OnButtonBrowseClick (object sender, EventArgs e) private void OnButtonLoadClick (object sender, EventArgs e) { - Result = DetermineResult(MissingFilesDialogResult.LoadValidFiles); + Result = DetermineLayoutResult(); + UpdateSessionFile = false; DialogResult = DialogResult.OK; Close(); } private void OnButtonLoadAndUpdateClick (object sender, EventArgs e) { - Result = DetermineResult(MissingFilesDialogResult.LoadAndUpdateSession); + Result = DetermineLayoutResult(); + UpdateSessionFile = true; DialogResult = DialogResult.OK; Close(); } @@ -432,9 +410,10 @@ private void OnButtonLoadAndUpdateClick (object sender, EventArgs e) private void OnButtonCancelClick (object sender, EventArgs e) { Result = MissingFilesDialogResult.Cancel; + UpdateSessionFile = false; DialogResult = DialogResult.Cancel; Close(); } #endregion -} +} \ No newline at end of file From bc2bb2bbccc27681464ca920460e2d542133340f Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Tue, 16 Dec 2025 16:34:54 +0100 Subject: [PATCH 06/10] fixing https://github.com/LogExperts/LogExpert/issues/517 --- src/LogExpert.Configuration/ConfigManager.cs | 8 ++++++++ src/LogExpert.Core/Interface/IConfigManager.cs | 9 +++++++++ src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs | 5 +++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/LogExpert.Configuration/ConfigManager.cs b/src/LogExpert.Configuration/ConfigManager.cs index c13c5a0a..5b8e716a 100644 --- a/src/LogExpert.Configuration/ConfigManager.cs +++ b/src/LogExpert.Configuration/ConfigManager.cs @@ -281,6 +281,14 @@ public void AddToFileHistory (string fileName) Save(SettingsFlags.FileHistory); } + public void ClearLastOpenFilesList () + { + lock (_loadSaveLock) + { + Instance.Settings.LastOpenFilesList.Clear(); + } + } + #endregion #region Private Methods diff --git a/src/LogExpert.Core/Interface/IConfigManager.cs b/src/LogExpert.Core/Interface/IConfigManager.cs index 14de0460..a436cb23 100644 --- a/src/LogExpert.Core/Interface/IConfigManager.cs +++ b/src/LogExpert.Core/Interface/IConfigManager.cs @@ -151,4 +151,13 @@ public interface IConfigManager /// This method is supported only on Windows platforms. /// The name of the file to add to the file history list. Comparison is case-insensitive. void AddToFileHistory (string fileName); + + /// + /// Clears the list of recently opened files. + /// + /// Call this method to remove all entries from the recent files list, typically to reset user + /// history or in response to a privacy-related action. After calling this method, the list of last open files will + /// be empty until new files are opened. + + void ClearLastOpenFilesList (); } \ No newline at end of file diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 725e66e8..85fecd78 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -909,8 +909,7 @@ private void DestroyBookmarkWindow () private void SaveLastOpenFilesList () { - ConfigManager.Settings.LastOpenFilesList.Clear(); - foreach (DockContent content in dockPanel.Contents) + foreach (DockContent content in dockPanel.Contents.Cast()) { if (content is LogWindow.LogWindow logWin) { @@ -2246,6 +2245,8 @@ private void OnLogTabWindowLoad (object sender, EventArgs e) AddFileTab(name, false, null, false, null); } } + + ConfigManager.ClearLastOpenFilesList(); } if (_startupFileNames != null) From a97e78e7c7cd33c54d43a2e0c64b039edf2342f0 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Tue, 16 Dec 2025 16:38:44 +0100 Subject: [PATCH 07/10] unittest fix --- src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs index 789dd8e5..0e47695c 100644 --- a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs +++ b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs @@ -175,7 +175,7 @@ public void PersisterHelpers_FindFilenameForSettings_ListOfFiles_ResolvesAll () }; // Act - call the List overload explicitly - var result = PersisterHelpers.FindFilenameForSettings(fileList, PluginRegistry.PluginRegistry.Instance); + var result = PersisterHelpers.FindFilenameForSettings(fileList.AsReadOnly(), PluginRegistry.PluginRegistry.Instance); // Assert Assert.That(result, Has.Count.EqualTo(3), "Should resolve all files"); @@ -198,7 +198,7 @@ public void PersisterHelpers_FindFilenameForSettings_MixedLxpAndLog_ResolvesBoth }; // Act - call the List overload explicitly - var result = PersisterHelpers.FindFilenameForSettings(fileList, PluginRegistry.PluginRegistry.Instance); + var result = PersisterHelpers.FindFilenameForSettings(fileList.AsReadOnly(), PluginRegistry.PluginRegistry.Instance); // Assert Assert.That(result, Has.Count.EqualTo(2)); From f8a839001e9261de28904ec813768c61770c743c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Dec 2025 15:42:34 +0000 Subject: [PATCH 08/10] 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 e5d5109a..eca09ab0 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: 2025-12-12 22:08:58 UTC + /// Generated: 2025-12-16 15:42:33 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "9BBF1F553FD9E5A9F669F107232985F48D93C6D132069464700201247CEDE5DD", + ["AutoColumnizer.dll"] = "6921D805A32744D305636EE7FBEBBB86E27CDA1494F92E76A5C3298BB1F7E319", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "B67093810D0144BAC80E70F981590AEF5E341C13852F151633345B5D1C4A33E5", - ["CsvColumnizer.dll (x86)"] = "B67093810D0144BAC80E70F981590AEF5E341C13852F151633345B5D1C4A33E5", - ["DefaultPlugins.dll"] = "76ABF37B8C9DD574EE6D9C42860D4D60818EA0407B6B3EBB03F31B33C8DCC50A", - ["FlashIconHighlighter.dll"] = "CDDD76BC56EAFDB6D5003E3EFC80122F4D20BE05F59F71FB094127C5FE02D700", - ["GlassfishColumnizer.dll"] = "198BECCD93612FC5041EE37E5E69E2352AC7A18416EAA7E205FB99133AB8261C", - ["JsonColumnizer.dll"] = "9E801A0C414CF6512087730906CDD9759908CD78CAB547B55C4DE16727B10922", - ["JsonCompactColumnizer.dll"] = "70EABBEA5CA5B32255CFB02C87B6512CE9C3B20B21F444DC5716E3F8A7512FD0", - ["Log4jXmlColumnizer.dll"] = "6F64839262E7DBEF08000812D84FC591965835B74EF8551C022E827070A136A0", - ["LogExpert.Core.dll"] = "F07BD482E92D8E17669C71F020BD392979177BE8E4F43AE3F4EC544411EB849E", - ["LogExpert.Resources.dll"] = "ED0E7ABC183982C10D7F48ECEA12B3AA4F2D518298E6BFB168F2E0921EF063DB", + ["CsvColumnizer.dll"] = "5087591DC589474704DF8D04CF301050CB801F028AC616B82CCCAD4D3AA4891B", + ["CsvColumnizer.dll (x86)"] = "5087591DC589474704DF8D04CF301050CB801F028AC616B82CCCAD4D3AA4891B", + ["DefaultPlugins.dll"] = "3B475A9FA455B517A74759998C56B9E54FF1D1DF1EF3E275DCC6650B2DDA5237", + ["FlashIconHighlighter.dll"] = "E8BE996919D2344C4B62BCC0B7D2FD92C13C409C137D46226A80B3618E064AD9", + ["GlassfishColumnizer.dll"] = "DF54B4799F45ADF223C077DE39CBC51B2C5A8676B8A42408C7C880B415E23F9B", + ["JsonColumnizer.dll"] = "A6C77FFD1EF60799B6C1F98F5CBD202EC557DCECA07B6BB43C085621E919DB3F", + ["JsonCompactColumnizer.dll"] = "E6E59164C7A0E11B1DFBBE967B87EBC1F4A9D2A67897A14D9ACAC638CB94C06E", + ["Log4jXmlColumnizer.dll"] = "FFD2DD18F6C3B90074D9ED6977C69017CC120AFC7EA15165D3778BFF1BFB1465", + ["LogExpert.Core.dll"] = "CFF14EC2674346BAE98FEFCFE866D678E699A0FF5D2C9A519FC02C94D870089F", + ["LogExpert.Resources.dll"] = "9CAFDB9C33165BDDC936BAD51547804A2243BF064ABD0D93C6D0604455167033", ["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"] = "A4C92D2E70491F3D65CDA46106F08277562950800FB0DE8230201CE129F5FB5C", - ["SftpFileSystem.dll"] = "D9643E14F3CF02F849BA12618D6F092ABEA07724ED9837F858AD88317E28B4C8", - ["SftpFileSystem.dll (x86)"] = "E1B167E38290E50E5EDC0754804792425363DA8EC7C38645C7AFA1ECB7118157", - ["SftpFileSystem.Resources.dll"] = "445FEAD03A0B535813A737A41B0C62C9E9341578FD3EACA3309766683B774561", - ["SftpFileSystem.Resources.dll (x86)"] = "445FEAD03A0B535813A737A41B0C62C9E9341578FD3EACA3309766683B774561", + ["RegexColumnizer.dll"] = "DA535890FBE92C03D3FFBF162B7C7E2DDA4BBCECDA65C80ADB7B88A8F6500082", + ["SftpFileSystem.dll"] = "C5E1970B1B7CC891785ACCBEF67F335163AEDA8076E3BB0360F67CB364B2551A", + ["SftpFileSystem.dll (x86)"] = "CB4A3F5619D4E1F3815D9A7E97A3B68AAC11BA8EECE71469CDE9ADCCFB61E0E4", + ["SftpFileSystem.Resources.dll"] = "A0499AA16FC49D5EF2BC608B087FB633D814CDCEA6B5CCE7067D62FC276473B4", + ["SftpFileSystem.Resources.dll (x86)"] = "A0499AA16FC49D5EF2BC608B087FB633D814CDCEA6B5CCE7067D62FC276473B4", }; } From 2c79a872c9903be387642cc4abccebf27c3bec60 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Tue, 16 Dec 2025 16:48:15 +0100 Subject: [PATCH 09/10] review comments --- .../Classes/Persister/ProjectFileValidator.cs | 39 ++++++++----------- .../ProjectFileValidatorTests.cs | 3 +- .../Dialogs/MissingFilesDialog.cs | 6 ++- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs b/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs index 742918a0..aa560354 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectFileValidator.cs @@ -146,14 +146,10 @@ private static List FindAlternativePaths (string fileName, string projec // Also check subdirectories (one level deep) var subdirs = Directory.GetDirectories(projectDir); - foreach (var subdir in subdirs) - { - var subdirCandidate = Path.Join(subdir, baseName); - if (File.Exists(subdirCandidate)) - { - alternatives.Add(subdirCandidate); - } - } + alternatives.AddRange( + subdirs + .Select(subdir => Path.Join(subdir, baseName)) + .Where(File.Exists)); } } catch (Exception ex) when (ex is ArgumentException or @@ -181,10 +177,10 @@ UnauthorizedAccessException or } } catch (Exception ex) when (ex is ArgumentException or - ArgumentNullException or - PathTooLongException or - UnauthorizedAccessException or - IOException) + ArgumentNullException or + PathTooLongException or + UnauthorizedAccessException or + IOException) { // Ignore errors when searching in Documents folder } @@ -203,23 +199,20 @@ UnauthorizedAccessException or var originalDrive = Path.GetPathRoot(fileName)?[0]; var pathWithoutDrive = fileName.Length > 3 ? fileName[3..] : string.Empty; - foreach (var drive in driveLetters) + foreach (var drive in driveLetters.Where(drive => drive != originalDrive && !string.IsNullOrEmpty(pathWithoutDrive))) { - if (drive != originalDrive && !string.IsNullOrEmpty(pathWithoutDrive)) + var alternatePath = $"{drive}:\\{pathWithoutDrive}"; + if (File.Exists(alternatePath) && !alternatives.Contains(alternatePath)) { - var alternatePath = $"{drive}:\\{pathWithoutDrive}"; - if (File.Exists(alternatePath) && !alternatives.Contains(alternatePath)) - { - alternatives.Add(alternatePath); - } + alternatives.Add(alternatePath); } } } catch (Exception ex) when (ex is ArgumentException or - ArgumentNullException or - PathTooLongException or - UnauthorizedAccessException or - IOException) + ArgumentNullException or + PathTooLongException or + UnauthorizedAccessException or + IOException) { // Ignore errors when searching on different drives } diff --git a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs index 0e47695c..ad21d5ff 100644 --- a/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs +++ b/src/LogExpert.Persister.Tests/ProjectFileValidatorTests.cs @@ -102,9 +102,8 @@ private void CreatePersistenceFile (string lxpFileName, string logFileName) /// private void DeleteLogFiles (params string[] fileNames) { - foreach (var fileName in fileNames) + foreach (var filePath in fileNames.Select(fileName => Path.Join(_testDirectory, fileName))) { - var filePath = Path.Join(_testDirectory, fileName); if (File.Exists(filePath)) { File.Delete(filePath); diff --git a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs index d0ff4e7e..04d8b34d 100644 --- a/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs +++ b/src/LogExpert.UI/Dialogs/MissingFilesDialog.cs @@ -266,7 +266,6 @@ private void UpdateSummary () /// Opens a file browser dialog for the specified missing file. /// /// The file item to browse for - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentionally Left Blank")] private void BrowseForFile (MissingFileItem fileItem) { using var openFileDialog = new OpenFileDialog @@ -287,7 +286,10 @@ private void BrowseForFile (MissingFileItem fileItem) openFileDialog.InitialDirectory = directory; } } - catch + catch (Exception ex) when (ex is ArgumentException or + PathTooLongException or + NotSupportedException or + UnauthorizedAccessException) { // Ignore if path is invalid } From fe6f6d1f916dc51b939f2e0eae18cd6dbdb508c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Dec 2025 15:51:21 +0000 Subject: [PATCH 10/10] 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 eca09ab0..7a878dc0 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: 2025-12-16 15:42:33 UTC + /// Generated: 2025-12-16 15:51:20 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "6921D805A32744D305636EE7FBEBBB86E27CDA1494F92E76A5C3298BB1F7E319", + ["AutoColumnizer.dll"] = "FBFCB4C9FEBF8DA0DDB1822BDF7CFCADF1185574917D8705C597864D1ACBFE0C", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "5087591DC589474704DF8D04CF301050CB801F028AC616B82CCCAD4D3AA4891B", - ["CsvColumnizer.dll (x86)"] = "5087591DC589474704DF8D04CF301050CB801F028AC616B82CCCAD4D3AA4891B", - ["DefaultPlugins.dll"] = "3B475A9FA455B517A74759998C56B9E54FF1D1DF1EF3E275DCC6650B2DDA5237", - ["FlashIconHighlighter.dll"] = "E8BE996919D2344C4B62BCC0B7D2FD92C13C409C137D46226A80B3618E064AD9", - ["GlassfishColumnizer.dll"] = "DF54B4799F45ADF223C077DE39CBC51B2C5A8676B8A42408C7C880B415E23F9B", - ["JsonColumnizer.dll"] = "A6C77FFD1EF60799B6C1F98F5CBD202EC557DCECA07B6BB43C085621E919DB3F", - ["JsonCompactColumnizer.dll"] = "E6E59164C7A0E11B1DFBBE967B87EBC1F4A9D2A67897A14D9ACAC638CB94C06E", - ["Log4jXmlColumnizer.dll"] = "FFD2DD18F6C3B90074D9ED6977C69017CC120AFC7EA15165D3778BFF1BFB1465", - ["LogExpert.Core.dll"] = "CFF14EC2674346BAE98FEFCFE866D678E699A0FF5D2C9A519FC02C94D870089F", - ["LogExpert.Resources.dll"] = "9CAFDB9C33165BDDC936BAD51547804A2243BF064ABD0D93C6D0604455167033", + ["CsvColumnizer.dll"] = "672B48664FDBDB7E71A01AEE6704D34544257B6A977E01EC0A514FAEE030E982", + ["CsvColumnizer.dll (x86)"] = "672B48664FDBDB7E71A01AEE6704D34544257B6A977E01EC0A514FAEE030E982", + ["DefaultPlugins.dll"] = "53FA9690E55C7A1C90601CCCCA4FFC808BB783DFAA1FDF42EB924DF3CDB65D40", + ["FlashIconHighlighter.dll"] = "AC101CC62538DB565C462D5A9A6EAA57D85AEA4954FAAEF271FEAC7F98FD1CB1", + ["GlassfishColumnizer.dll"] = "915BDD327C531973F3355BC5B24AE70B0EA618269E9367DA4FA919B4BA08D171", + ["JsonColumnizer.dll"] = "8A8B8021A4D146424947473A5FC2F0C07248BD4FAC079C00E586ADAA153CA9ED", + ["JsonCompactColumnizer.dll"] = "6B26775A81C6FC4232C08BBAFDCE35A0EFA80A367FC00271DF00E89AF2F74802", + ["Log4jXmlColumnizer.dll"] = "B645973F639B23A51D066DC283A92E3BDAF058934C6C6378B6F13247B75077CB", + ["LogExpert.Core.dll"] = "A370D1AEF2A790D16622CD15C67270799A87555996A7A0D0A4D98B950B9C3CB7", + ["LogExpert.Resources.dll"] = "1D761591625ED49FD0DE29ABDB2800D865BD99B359397CF7790004B3CC9E6738", ["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"] = "DA535890FBE92C03D3FFBF162B7C7E2DDA4BBCECDA65C80ADB7B88A8F6500082", - ["SftpFileSystem.dll"] = "C5E1970B1B7CC891785ACCBEF67F335163AEDA8076E3BB0360F67CB364B2551A", - ["SftpFileSystem.dll (x86)"] = "CB4A3F5619D4E1F3815D9A7E97A3B68AAC11BA8EECE71469CDE9ADCCFB61E0E4", - ["SftpFileSystem.Resources.dll"] = "A0499AA16FC49D5EF2BC608B087FB633D814CDCEA6B5CCE7067D62FC276473B4", - ["SftpFileSystem.Resources.dll (x86)"] = "A0499AA16FC49D5EF2BC608B087FB633D814CDCEA6B5CCE7067D62FC276473B4", + ["RegexColumnizer.dll"] = "DD44507671520B3E2CD93311A651EFC7632C757FDA85750470A6FFCB74F1AB65", + ["SftpFileSystem.dll"] = "16818A6B8B7178372CCB1CD6ED8C4B0542FDD7D10C8C9AAE76704F0ED2CC4362", + ["SftpFileSystem.dll (x86)"] = "94B725554D2BBF79BBB0B4776C01AE0D1E8F06C158F5D234C2D94F5E62A900BC", + ["SftpFileSystem.Resources.dll"] = "FEFD8C274032A70FDB03EA8363D7F877BEBAF2C454238820D6D17ECEF79FC8A4", + ["SftpFileSystem.Resources.dll (x86)"] = "FEFD8C274032A70FDB03EA8363D7F877BEBAF2C454238820D6D17ECEF79FC8A4", }; }