diff --git a/build/_build.csproj b/build/_build.csproj index 9573e682..a1f4a547 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index d2dc0368..918e0294 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -22,8 +22,8 @@ - - + + diff --git a/src/LogExpert.Tests/Services/ProjectFileHandlerTests.cs b/src/LogExpert.Tests/Services/ProjectFileHandlerTests.cs new file mode 100644 index 00000000..205d08f3 --- /dev/null +++ b/src/LogExpert.Tests/Services/ProjectFileHandlerTests.cs @@ -0,0 +1,652 @@ +using System.Runtime.Versioning; + +using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Config; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Interface; +using LogExpert.UI.Services.FileOperationService; +using LogExpert.UI.Services.ProjectFileHandlerService; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Services; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[SupportedOSPlatform("windows")] +internal class ProjectFileHandlerTests : IDisposable +{ + private Mock _pluginRegistryMock = null!; + private List _addFileTabCalls = null!; + private Settings _settings; + private Mock _configManagerMock; + private LogWindow _stubLogWindow = null!; + private ProjectFileHandler _sut = null!; + + private bool _disposed; + + [SetUp] + public void SetUp () + { + // Ensure the static PluginRegistry singleton is initialized. + // LogWindow's constructor accesses PluginRegistry.Instance.RegisteredColumnizers[0] directly. + _ = PluginRegistry.PluginRegistry.Create(Path.GetTempPath(), 250); + + _pluginRegistryMock = new Mock(); + _configManagerMock = new Mock(); + _addFileTabCalls = []; + + _settings = new Settings(); + _ = _configManagerMock.Setup(cm => cm.Settings).Returns(_settings); + + _ = _pluginRegistryMock.Setup(pr => pr.RegisteredColumnizers).Returns([]); + + // Create a minimal stub LogWindow for the callback. + // LogWindow requires an ILogWindowCoordinator + var coordinatorMock = new Mock(); + _stubLogWindow = new LogWindow( + coordinatorMock.Object, + "stub.log", + isTempFile: false, + forcePersistenceLoading: false, + configManager: _configManagerMock.Object); + + _sut = new ProjectFileHandler( + _pluginRegistryMock.Object, + request => + { + _addFileTabCalls.Add(request); + return _stubLogWindow; + }); + } + + [TearDown] + public void TearDown () + { + _stubLogWindow?.Dispose(); + } + + #region LoadProject — Phase 1 tests + + [Test] + public void LoadProject_ValidProjectFile_ReturnsSuccess () + { + // Arrange: create a real log file so validation passes + var logFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".log"); + File.WriteAllText(logFile, "test log line"); + var tempFile = CreateTempProjectFile([logFile], layoutXml: null); + + try + { + // Act + var outcome = _sut.LoadProject(tempFile); + + // Assert + Assert.That(outcome.Status, Is.EqualTo(ProjectLoadOutcome.LoadStatus.Success)); + Assert.That(outcome.ProjectData, Is.Not.Null); + Assert.That(outcome.ProjectData!.FileNames, Has.Count.EqualTo(1)); + Assert.That(outcome.ErrorMessage, Is.Null); + Assert.That(outcome.ValidationResult, Is.Null); + } + finally + { + File.Delete(tempFile); + File.Delete(logFile); + } + } + + [Test] + public void LoadProject_ValidProjectWithLayoutXml_HasLayoutDataIsTrue () + { + // Arrange: create a real log file so validation passes + var logFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".log"); + File.WriteAllText(logFile, "test log line"); + var tempFile = CreateTempProjectFile([logFile], layoutXml: ""); + + try + { + // Act + var outcome = _sut.LoadProject(tempFile); + + // Assert + Assert.That(outcome.Status, Is.EqualTo(ProjectLoadOutcome.LoadStatus.Success)); + Assert.That(outcome.HasLayoutData, Is.True); + Assert.That(outcome.LayoutXml, Is.EqualTo("")); + } + finally + { + File.Delete(tempFile); + File.Delete(logFile); + } + } + + [Test] + public void LoadProject_ValidProjectWithoutLayoutXml_HasLayoutDataIsFalse () + { + // Arrange + var tempFile = CreateTempProjectFile(["C:\\logs\\app.log"], layoutXml: null); + + try + { + // Act + var outcome = _sut.LoadProject(tempFile); + + // Assert + Assert.That(outcome.HasLayoutData, Is.False); + Assert.That(outcome.LayoutXml, Is.Null); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void LoadProject_ProjectWithMissingFiles_ReturnsNeedsIntervention () + { + // Arrange: use file paths that do not exist on disk + var tempFile = CreateTempProjectFile( + ["C:\\nonexistent\\path\\missing.log", "C:\\also\\missing.log"], + layoutXml: null); + + try + { + // Act + var outcome = _sut.LoadProject(tempFile); + + // Assert + Assert.That(outcome.Status, Is.EqualTo(ProjectLoadOutcome.LoadStatus.NeedsIntervention)); + Assert.That(outcome.ProjectData, Is.Not.Null); + Assert.That(outcome.ValidationResult, Is.Not.Null); + Assert.That(outcome.ValidationResult!.HasMissingFiles, Is.True); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void LoadProject_CorruptFile_ReturnsError () + { + // Arrange: write garbage content + var tempFile = Path.GetTempFileName(); + File.WriteAllText(tempFile, "{{{{not json not xml}}}}"); + + try + { + // Act + var outcome = _sut.LoadProject(tempFile); + + // Assert + Assert.That(outcome.Status, Is.EqualTo(ProjectLoadOutcome.LoadStatus.Error)); + Assert.That(outcome.ErrorMessage, Is.Not.Null.And.Not.Empty); + Assert.That(outcome.ProjectData, Is.Null); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void LoadProject_NonexistentFile_ReturnsError () + { + // Arrange + var fakePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".lxj"); + + // Act + var outcome = _sut.LoadProject(fakePath); + + // Assert + Assert.That(outcome.Status, Is.EqualTo(ProjectLoadOutcome.LoadStatus.Error)); + Assert.That(outcome.ErrorMessage, Is.Not.Null.And.Not.Empty); + } + + [Test] + public void LoadProject_EmptyFileList_ReturnsEmptyProject () + { + // Arrange: valid JSON but with zero files + var tempFile = CreateTempProjectFile([], layoutXml: null); + + try + { + // Act + var outcome = _sut.LoadProject(tempFile); + + // Assert + Assert.That(outcome.Status, Is.EqualTo(ProjectLoadOutcome.LoadStatus.EmptyProject)); + Assert.That(outcome.ErrorMessage, Is.Not.Null.And.Not.Empty); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void LoadProject_DoesNotInvokeAddFileTabCallback () + { + // Arrange + var tempFile = CreateTempProjectFile(["C:\\logs\\app.log"], layoutXml: null); + + try + { + // Act + _ = _sut.LoadProject(tempFile); + + // Assert — phase 1 must never open tabs + Assert.That(_addFileTabCalls, Is.Empty); + } + finally + { + File.Delete(tempFile); + } + } + + #endregion + + #region ContinueLoad tests + + [Test] + public void ContinueLoad_SuccessNoLayout_CallsAddFileTabWithDoNotAddToDockPanelFalse () + { + // Arrange + var outcome = CreateSuccessOutcome(["C:\\logs\\app.log"], layoutXml: null); + + // Act + var result = _sut.ContinueLoad(outcome, resolution: null, restoreLayout: true); + + // Assert + Assert.That(result.OpenedTabs, Is.True); + Assert.That(_addFileTabCalls, Has.Count.EqualTo(1)); + Assert.That(_addFileTabCalls[0].DoNotAddToDockPanel, Is.False); + Assert.That(_addFileTabCalls[0].FileName, Is.EqualTo("C:\\logs\\app.log")); + Assert.That(_addFileTabCalls[0].ForcePersistenceLoading, Is.True); + } + + [Test] + public void ContinueLoad_SuccessWithLayout_CallsAddFileTabWithDoNotAddToDockPanelTrue () + { + // Arrange + var outcome = CreateSuccessOutcome(["C:\\logs\\app.log"], layoutXml: ""); + + // Act + var result = _sut.ContinueLoad(outcome, resolution: null, restoreLayout: true); + + // Assert + Assert.That(result.OpenedTabs, Is.True); + Assert.That(_addFileTabCalls, Has.Count.EqualTo(1)); + Assert.That(_addFileTabCalls[0].DoNotAddToDockPanel, Is.True); + } + + [Test] + public void ContinueLoad_SuccessWithLayoutButRestoreLayoutFalse_DoNotAddToDockPanelIsFalse () + { + // Arrange + var outcome = CreateSuccessOutcome(["C:\\logs\\app.log"], layoutXml: ""); + + // Act + _ = _sut.ContinueLoad(outcome, resolution: null, restoreLayout: false); + + // Assert + Assert.That(_addFileTabCalls[0].DoNotAddToDockPanel, Is.False); + } + + [Test] + public void ContinueLoad_MultipleFiles_CallsAddFileTabForEachFile () + { + // Arrange + var outcome = CreateSuccessOutcome(["C:\\logs\\a.log", "C:\\logs\\b.log", "C:\\logs\\c.log"], layoutXml: null); + + // Act + var result = _sut.ContinueLoad(outcome, resolution: null, restoreLayout: true); + + // Assert + Assert.That(result.OpenedTabs, Is.True); + Assert.That(_addFileTabCalls, Has.Count.EqualTo(3)); + Assert.That(_addFileTabCalls[0].FileName, Is.EqualTo("C:\\logs\\a.log")); + Assert.That(_addFileTabCalls[1].FileName, Is.EqualTo("C:\\logs\\b.log")); + Assert.That(_addFileTabCalls[2].FileName, Is.EqualTo("C:\\logs\\c.log")); + } + + [Test] + public void ContinueLoad_WithAlternatives_ReplacesFilePathsBeforeOpening () + { + // Arrange + var outcome = CreateOutcome( + ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ["C:\\old\\missing.log", "C:\\logs\\existing.log"], + layoutXml: null); + + var resolution = new MissingFilesResolution + { + SelectedAlternatives = new Dictionary + { + ["C:\\old\\missing.log"] = "D:\\new\\found.log" + } + }; + + // Act + _ = _sut.ContinueLoad(outcome, resolution, restoreLayout: true); + + // Assert + Assert.That(_addFileTabCalls, Has.Count.EqualTo(2)); + Assert.That(_addFileTabCalls[0].FileName, Is.EqualTo("D:\\new\\found.log")); + Assert.That(_addFileTabCalls[1].FileName, Is.EqualTo("C:\\logs\\existing.log")); + } + + [Test] + public void ContinueLoad_DuplicateFileWithAlternative_ReplacesBothOccurrences () + { + // Arrange + var outcome = CreateOutcome( + ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ["C:\\missing.log", "C:\\missing.log"], + layoutXml: null); + + var resolution = new MissingFilesResolution + { + SelectedAlternatives = new Dictionary + { + ["C:\\missing.log"] = "D:\\found.log" + } + }; + + // Act + _ = _sut.ContinueLoad(outcome, resolution, restoreLayout: true); + + // Assert + Assert.That(_addFileTabCalls, Has.Count.EqualTo(2)); + Assert.That(_addFileTabCalls[0].FileName, Is.EqualTo("D:\\found.log")); + Assert.That(_addFileTabCalls[1].FileName, Is.EqualTo("D:\\found.log")); + } + + [Test] + public void ContinueLoad_CloseAllTabs_ReturnsTrueInResult () + { + // Arrange + var outcome = CreateOutcome( + ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ["C:\\logs\\app.log"], + layoutXml: ""); + + var resolution = new MissingFilesResolution { CloseAllTabs = true }; + + // Act + var result = _sut.ContinueLoad(outcome, resolution, restoreLayout: true); + + // Assert + Assert.That(result.CloseAllTabs, Is.True); + Assert.That(result.OpenedTabs, Is.True); + Assert.That(result.OpenInNewWindowFiles, Is.Null); + } + + [Test] + public void ContinueLoad_OpenInNewWindow_DoesNotOpenTabs_ReturnsFileNames () + { + // Arrange + var outcome = CreateOutcome( + ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ["C:\\logs\\app.log"], + layoutXml: null); + + var resolution = new MissingFilesResolution { OpenInNewWindow = true }; + + // Act + var result = _sut.ContinueLoad(outcome, resolution, restoreLayout: true); + + // Assert + Assert.That(result.OpenedTabs, Is.False); + Assert.That(result.OpenInNewWindowFiles, Is.Not.Null); + Assert.That(result.OpenInNewWindowFiles, Has.Length.EqualTo(1)); + Assert.That(_addFileTabCalls, Is.Empty, "OpenInNewWindow must not open tabs"); + } + + [Test] + public void ContinueLoad_OpenInNewWindowWithAlternatives_AppliesAlternativesThenResolves () + { + // Arrange + var outcome = CreateOutcome( + ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ["C:\\old\\missing.log", "C:\\logs\\existing.log"], + layoutXml: null); + + var resolution = new MissingFilesResolution + { + OpenInNewWindow = true, + SelectedAlternatives = new Dictionary + { + ["C:\\old\\missing.log"] = "D:\\new\\found.log" + } + }; + + // Act + var result = _sut.ContinueLoad(outcome, resolution, restoreLayout: true); + + // Assert + Assert.That(result.OpenInNewWindowFiles, Is.Not.Null); + Assert.That(result.OpenInNewWindowFiles, Has.Length.EqualTo(2)); + // First file should be the alternative, second should be unchanged + // Exact values depend on PersisterHelpers.FindFilenameForSettings resolution + Assert.That(result.OpenInNewWindowFiles![0], Does.Contain("found.log")); + } + + [Test] + public void ContinueLoad_UpdateSessionFileTrue_SavesAmendedProject () + { + // Arrange: create a real temp .lxj so we can verify it was updated + var tempFile = CreateTempProjectFile(["C:\\old\\missing.log"], layoutXml: ""); + + try + { + var outcome = new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ProjectData = new ProjectData + { + FileNames = ["C:\\old\\missing.log"], + TabLayoutXml = "", + ProjectFilePath = tempFile + }, + LayoutXml = "" + }; + + var resolution = new MissingFilesResolution + { + UpdateSessionFile = true, + SelectedAlternatives = new Dictionary + { + ["C:\\old\\missing.log"] = "D:\\new\\found.log" + } + }; + + // Act + _ = _sut.ContinueLoad(outcome, resolution, restoreLayout: false); + + // Assert: reload the file and check the path was updated + var reloaded = ProjectPersister.LoadProjectData(tempFile, _pluginRegistryMock.Object); + Assert.That(reloaded?.ProjectData?.FileNames, Has.Some.Contains("found.log")); + // Layout XML must be preserved + Assert.That(reloaded?.ProjectData?.TabLayoutXml, Is.EqualTo("")); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void ContinueLoad_UpdateSessionFileFalse_DoesNotSave () + { + // Arrange + var tempFile = CreateTempProjectFile(["C:\\old\\missing.log"], layoutXml: null); + + try + { + var originalContent = File.ReadAllText(tempFile); + + var outcome = new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ProjectData = new ProjectData + { + FileNames = ["C:\\old\\missing.log"], + ProjectFilePath = tempFile + } + }; + + var resolution = new MissingFilesResolution + { + UpdateSessionFile = false, + SelectedAlternatives = new Dictionary + { + ["C:\\old\\missing.log"] = "D:\\new\\found.log" + } + }; + + // Act + _ = _sut.ContinueLoad(outcome, resolution, restoreLayout: false); + + // Assert: file content unchanged + Assert.That(File.ReadAllText(tempFile), Is.EqualTo(originalContent)); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void ContinueLoad_NullResolution_SuccessReturnsCloseAllTabsFalse () + { + // Arrange + var outcome = CreateSuccessOutcome(["C:\\logs\\app.log"], layoutXml: null); + + // Act + var result = _sut.ContinueLoad(outcome, resolution: null, restoreLayout: true); + + // Assert + Assert.That(result.CloseAllTabs, Is.False); + Assert.That(result.OpenInNewWindowFiles, Is.Null); + } + + #endregion + + #region SaveProject tests + + [Test] + public void SaveProject_ValidData_ReturnsTrueAndNullError () + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".lxj"); + var projectData = new ProjectData + { + FileNames = ["C:\\logs\\app.log"], + TabLayoutXml = "" + }; + + try + { + // Act + var success = _sut.SaveProject(tempFile, projectData, out var errorMessage); + + // Assert + Assert.That(success, Is.True); + Assert.That(errorMessage, Is.Null); + Assert.That(File.Exists(tempFile), Is.True); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Test] + public void SaveProject_NullPath_ReturnsFalseWithErrorMessage () + { + // Note: ProjectPersister.SaveProjectData swallows IOException/UnauthorizedAccessException/ + // JsonSerializationException. Only uncaught exceptions (e.g. ArgumentNullException from + // File.WriteAllText(null,...)) propagate to ProjectFileHandler.SaveProject's catch block. + var projectData = new ProjectData + { + FileNames = ["C:\\logs\\app.log"] + }; + + // Act + var success = _sut.SaveProject(null!, projectData, out var errorMessage); + + // Assert + Assert.That(success, Is.False); + Assert.That(errorMessage, Is.Not.Null.And.Not.Empty); + } + + #endregion + + #region Helpers + + private static ProjectLoadOutcome CreateSuccessOutcome (List fileNames, string? layoutXml) + { + return CreateOutcome(ProjectLoadOutcome.LoadStatus.Success, fileNames, layoutXml); + } + + private static ProjectLoadOutcome CreateOutcome (ProjectLoadOutcome.LoadStatus status, List fileNames, string? layoutXml) + { + return new ProjectLoadOutcome + { + Status = status, + ProjectData = new ProjectData + { + FileNames = fileNames, + TabLayoutXml = layoutXml! + }, + LayoutXml = layoutXml + }; + } + + /// + /// Creates a temporary .lxj project file with the given file names and optional layout XML. Returns the path to the + /// temp file. + /// + private static string CreateTempProjectFile (List fileNames, string? layoutXml) + { + var projectData = new ProjectData + { + FileNames = fileNames, + TabLayoutXml = layoutXml! + }; + + var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".lxj"); + ProjectPersister.SaveProjectData(tempFile, projectData); + return tempFile; + } + + #endregion + + public void Dispose () + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose (bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _stubLogWindow?.Dispose(); + } + + _disposed = true; + } +} \ 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 579e3507..0f6199d7 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -24,6 +24,7 @@ using LogExpert.UI.Services.LedService; using LogExpert.UI.Services.LogWindowCoordinatorService; using LogExpert.UI.Services.MenuToolbarService; +using LogExpert.UI.Services.ProjectFileHandlerService; using LogExpert.UI.Services.TabControllerService; using LogExpert.UI.Services.ToolWindowCoordinatorService; @@ -51,6 +52,7 @@ internal partial class LogTabWindow : Form, ILogTabWindow private readonly LogWindowCoordinator _logWindowCoordinator; private readonly ToolWindowCoordinator _toolWindowCoordinator; private readonly FileOperationService _fileOperationService; + private readonly ProjectFileHandler _projectFileHandler; private bool _disposed; @@ -110,6 +112,8 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu _fileOperationService.FileHistoryChanged += (_, _) => FillHistoryMenu(); _fileOperationService.FileOpened += OnFileOperationServiceFileOpened; + _projectFileHandler = new ProjectFileHandler(PluginRegistry.PluginRegistry.Instance, request => _fileOperationService.AddFileTab(request)); + _logWindowCoordinator = new LogWindowCoordinator(configManager, PluginRegistry.PluginRegistry.Instance, this, _tabController, _ledService, _fileOperationService); //Fix MainMenu and externalToolsToolStrip.Location, if the location has been changed in the designer @@ -1422,109 +1426,88 @@ private void CloseAllTabs () [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 - { - // Load project with validation - var loadResult = ProjectPersister.LoadProjectData(projectFileName, PluginRegistry.PluginRegistry.Instance); + var outcome = _projectFileHandler.LoadProject(projectFileName); + bool openedTabs = false; - if (loadResult?.ProjectData == null) - { - ShowOkMessage( - Resources.LoadProject_UI_Message_Error_FileMaybeCorruptedOrInaccessible, - Resources.LoadProject_UI_Message_Error_Title_ProjectLoadFailed, - MessageBoxIcon.Error); + switch (outcome.Status) + { + case ProjectLoadOutcome.LoadStatus.Error: + { + ShowOkMessage(outcome.ErrorMessage ?? Resources.LogExpert_Common_UI_Title_Error, + Resources.LoadProject_UI_Message_Error_Title_ProjectLoadFailed, + MessageBoxIcon.Error); + return; + } + case ProjectLoadOutcome.LoadStatus.EmptyProject: + { + ShowOkMessage(outcome.ErrorMessage ?? Resources.LoadProject_UI_Message_Error_Title_SessionLoadFailed, + Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound, + MessageBoxIcon.Error); + return; + } + case ProjectLoadOutcome.LoadStatus.NeedsIntervention: + { + var (dialogResult, updateSessionFile, selectedAlternatives) = + MissingFilesDialog.ShowDialog(outcome.ValidationResult!, outcome.HasLayoutData); - return; - } + if (dialogResult == MissingFilesDialogResult.Cancel) + { + return; + } - var projectData = loadResult.ProjectData; - var hasLayoutData = projectData.TabLayoutXml != null; + if (dialogResult == MissingFilesDialogResult.IgnoreLayout) + { + restoreLayout = false; + } - if (projectData.FileNames.Count == 0) - { - ShowOkMessage( - Resources.LoadProject_UI_Message_Error_Title_SessionLoadFailed, - Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound, - MessageBoxIcon.Error); - return; - } + var resolution = new MissingFilesResolution + { + CloseAllTabs = dialogResult == MissingFilesDialogResult.CloseTabsAndRestoreLayout, + OpenInNewWindow = dialogResult == MissingFilesDialogResult.OpenInNewWindow, + UpdateSessionFile = updateSessionFile, + SelectedAlternatives = selectedAlternatives + }; - // Handle missing files or layout options - if (loadResult.RequiresUserIntervention) - { - // Show enhanced dialog with browsing capability and layout options - var (dialogResult, updateSessionFile, selectedAlternatives) = MissingFilesDialog.ShowDialog(loadResult.ValidationResult, hasLayoutData); + var interventionResult = _projectFileHandler.ContinueLoad(outcome, resolution, restoreLayout); - if (dialogResult == MissingFilesDialogResult.Cancel) - { - return; - } + if (updateSessionFile) + { + ShowOkMessage(Resources.LoadProject_UI_Message_Error_Message_UpdateSessionFile, + Resources.LoadProject_UI_Message_Error_Title_UpdateSessionFile, + MessageBoxIcon.Information); + } - if (updateSessionFile) - { - // Replace original paths with selected alternatives in project data - for (int i = 0; i < projectData.FileNames.Count; i++) + if (interventionResult.CloseAllTabs) { - var originalPath = projectData.FileNames[i]; - if (selectedAlternatives.TryGetValue(originalPath, out string value)) - { - projectData.FileNames[i] = value; - } + CloseAllTabs(); } - ProjectPersister.SaveProjectData(projectFileName, projectData); + if (interventionResult.OpenInNewWindowFiles is not null) + { + LogExpertProxy.NewWindow([.. interventionResult.OpenInNewWindowFiles]); + return; + } - ShowOkMessage( - Resources.LoadProject_UI_Message_Error_Message_UpdateSessionFile, - Resources.LoadProject_UI_Message_Error_Title_UpdateSessionFile, - MessageBoxIcon.Information); + openedTabs = interventionResult.OpenedTabs; + break; } - - // Handle layout-related results - switch (dialogResult) + case ProjectLoadOutcome.LoadStatus.Success: { - case MissingFilesDialogResult.CloseTabsAndRestoreLayout: - CloseAllTabs(); - break; - case MissingFilesDialogResult.OpenInNewWindow: - { - var logFileNames = PersisterHelpers.FindFilenameForSettings(projectData.FileNames.AsReadOnly(), PluginRegistry.PluginRegistry.Instance); - LogExpertProxy.NewWindow([.. logFileNames]); - return; - } - case MissingFilesDialogResult.IgnoreLayout: - hasLayoutData = false; - break; + openedTabs = _projectFileHandler.ContinueLoad(outcome, null, restoreLayout).OpenedTabs; + break; } - } - - foreach (var fileName in projectData.FileNames) - { - _ = hasLayoutData - ? _fileOperationService.AddFileTabDeferred(fileName, false, null, true, null) - : _fileOperationService.AddFileTab(new FileTabRequest { FileName = fileName, ForcePersistenceLoading = true }); - } + } - // Restore layout only if we loaded at least one file - if (hasLayoutData && restoreLayout && _tabController.GetWindowCount() > 0) - { - _logger.Info("Restoring layout"); - // Re-creating tool (non-document) windows is needed because the DockPanel control would throw strange errors - DestroyBookmarkWindow(); - InitToolWindows(); - RestoreLayout(projectData.TabLayoutXml); - } - else if (_tabController.GetWindowCount() == 0) - { - _logger.Warn("No files loaded, skipping layout restoration"); - } + if (restoreLayout && outcome.HasLayoutData && openedTabs) + { + _logger.Info("Restoring layout"); + DestroyBookmarkWindow(); + InitToolWindows(); + RestoreLayout(outcome.LayoutXml!); } - catch (Exception ex) + else if (!openedTabs) { - ShowOkMessage( - $"Error loading project: {ex.Message}", - Resources.LogExpert_Common_UI_Title_Error, - MessageBoxIcon.Error); + _logger.Warn("No files loaded, skipping layout restoration"); } } @@ -2396,7 +2379,12 @@ private void OnSaveProjectToolStripMenuItemClick (object sender, EventArgs e) TabLayoutXml = SaveLayout() }; - ProjectPersister.SaveProjectData(fileName, projectData); + if (!_projectFileHandler.SaveProject(fileName, projectData, out var errorMessage)) + { + ShowOkMessage(errorMessage ?? Resources.LogExpert_Common_UI_Title_Error, + Resources.LogExpert_Common_UI_Title_Error, + MessageBoxIcon.Error); + } } } diff --git a/src/LogExpert.UI/Interface/IProjectFileHandler.cs b/src/LogExpert.UI/Interface/IProjectFileHandler.cs new file mode 100644 index 00000000..dff9d966 --- /dev/null +++ b/src/LogExpert.UI/Interface/IProjectFileHandler.cs @@ -0,0 +1,13 @@ +using LogExpert.Core.Classes.Persister; +using LogExpert.UI.Services.ProjectFileHandlerService; + +namespace LogExpert.UI.Interface; + +internal interface IProjectFileHandler +{ + ProjectLoadOutcome LoadProject (string projectFileName); + + ContinueLoadResult ContinueLoad (ProjectLoadOutcome loadOutcome, MissingFilesResolution? resolution, bool restoreLayout); + + bool SaveProject (string projectFileName, ProjectData projectData, out string? errorMessage); +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/ProjectFileHandlerService/ContinueLoadResult.cs b/src/LogExpert.UI/Services/ProjectFileHandlerService/ContinueLoadResult.cs new file mode 100644 index 00000000..ee71c88a --- /dev/null +++ b/src/LogExpert.UI/Services/ProjectFileHandlerService/ContinueLoadResult.cs @@ -0,0 +1,10 @@ +namespace LogExpert.UI.Services.ProjectFileHandlerService; + +internal readonly record struct ContinueLoadResult +{ + public bool OpenedTabs { get; init; } + + public bool CloseAllTabs { get; init; } + + public string[]? OpenInNewWindowFiles { get; init; } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/ProjectFileHandlerService/MissingFilesResolution.cs b/src/LogExpert.UI/Services/ProjectFileHandlerService/MissingFilesResolution.cs new file mode 100644 index 00000000..0a14b1b3 --- /dev/null +++ b/src/LogExpert.UI/Services/ProjectFileHandlerService/MissingFilesResolution.cs @@ -0,0 +1,12 @@ +namespace LogExpert.UI.Services.ProjectFileHandlerService; + +internal readonly record struct MissingFilesResolution +{ + public bool CloseAllTabs { get; init; } + + public bool OpenInNewWindow { get; init; } + + public bool UpdateSessionFile { get; init; } + + public IReadOnlyDictionary? SelectedAlternatives { get; init; } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/ProjectFileHandlerService/ProjectFileHandler.cs b/src/LogExpert.UI/Services/ProjectFileHandlerService/ProjectFileHandler.cs new file mode 100644 index 00000000..afa01c4b --- /dev/null +++ b/src/LogExpert.UI/Services/ProjectFileHandlerService/ProjectFileHandler.cs @@ -0,0 +1,186 @@ +using System.Runtime.Versioning; + +using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Interface; +using LogExpert.UI.Services.FileOperationService; + +using NLog; + +namespace LogExpert.UI.Services.ProjectFileHandlerService; + +[SupportedOSPlatform("windows")] +internal sealed class ProjectFileHandler ( + IPluginRegistry pluginRegistry, + Func addFileTab) + : IProjectFileHandler +{ + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + public ProjectLoadOutcome LoadProject (string projectFileName) + { + try + { + if (!File.Exists(projectFileName)) + { + _logger.Warn("LoadProject: File does not exist: {FileName}", projectFileName); + + return new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.Error, + ErrorMessage = $"Project file not found: {projectFileName}" + }; + } + + var loadResult = ProjectPersister.LoadProjectData(projectFileName, pluginRegistry); + + if (loadResult?.ProjectData == null) + { + _logger.Warn("LoadProject: ProjectData is null for {FileName}", projectFileName); + + return new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.Error, + ErrorMessage = Resources.LoadProject_UI_Message_Error_FileMaybeCorruptedOrInaccessible + }; + } + + var projectData = loadResult.ProjectData; + + if (projectData.FileNames.Count == 0) + { + _logger.Warn("LoadProject: No files in project {FileName}", projectFileName); + + return new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.EmptyProject, + ErrorMessage = Resources.LoadProject_UI_Message_Message_FilesForSessionCouldNotBeFound + }; + } + + var layoutXml = projectData.TabLayoutXml; + + return loadResult.RequiresUserIntervention + ? new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.NeedsIntervention, + ProjectData = projectData, + ValidationResult = loadResult.ValidationResult, + LayoutXml = layoutXml + } + : new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.Success, + ProjectData = projectData, + LayoutXml = layoutXml + }; + } + catch (Exception ex) + { + _logger.Error(ex, "LoadProject: Exception loading {FileName}", projectFileName); + + return new ProjectLoadOutcome + { + Status = ProjectLoadOutcome.LoadStatus.Error, + ErrorMessage = $"Error loading project: {ex.Message}" + }; + } + } + + public ContinueLoadResult ContinueLoad (ProjectLoadOutcome loadOutcome, MissingFilesResolution? resolution, bool restoreLayout) + { + ArgumentNullException.ThrowIfNull(loadOutcome); + ArgumentNullException.ThrowIfNull(loadOutcome.ProjectData); + + var projectData = loadOutcome.ProjectData; + + // Apply selected alternatives to file paths (in-place, exact string match) + if (resolution?.SelectedAlternatives is { } alternatives) + { + for (int i = 0; i < projectData.FileNames.Count; i++) + { + var originalPath = projectData.FileNames[i]; + if (alternatives.TryGetValue(originalPath, out var replacement)) + { + projectData.FileNames[i] = replacement; + } + } + } + + // Save updated session file if requested + if (resolution is { UpdateSessionFile: true } && !string.IsNullOrEmpty(projectData.ProjectFilePath)) + { + try + { + ProjectPersister.SaveProjectData(projectData.ProjectFilePath, projectData); + _logger.Info("ContinueLoad: Updated session file {FileName}", projectData.ProjectFilePath); + } + catch (Exception ex) + { + _logger.Error(ex, "ContinueLoad: Failed to update session file {FileName}", projectData.ProjectFilePath); + } + } + + // Handle "Open in new window" — resolve file names but do NOT open tabs + if (resolution is { OpenInNewWindow: true }) + { + var resolvedFiles = PersisterHelpers.FindFilenameForSettings( + projectData.FileNames.AsReadOnly(), pluginRegistry); + + return new ContinueLoadResult + { + OpenedTabs = false, + CloseAllTabs = false, + OpenInNewWindowFiles = [.. resolvedFiles] + }; + } + + // Open file tabs + bool deferForLayout = loadOutcome.HasLayoutData && restoreLayout; + int openedCount = 0; + + foreach (var fileName in projectData.FileNames) + { + var request = new FileTabRequest + { + FileName = fileName, + ForcePersistenceLoading = true, + DoNotAddToDockPanel = deferForLayout + }; + + try + { + _ = addFileTab(request); + openedCount++; + } + catch (Exception ex) + { + _logger.Error(ex, "ContinueLoad: Failed to open tab for {FileName}", fileName); + } + } + + return new ContinueLoadResult + { + OpenedTabs = openedCount > 0, + CloseAllTabs = resolution?.CloseAllTabs ?? false, + OpenInNewWindowFiles = null + }; + } + + public bool SaveProject (string projectFileName, ProjectData projectData, out string? errorMessage) + { + try + { + ProjectPersister.SaveProjectData(projectFileName, projectData); + errorMessage = null; + return true; + } + catch (Exception ex) + { + _logger.Error(ex, "SaveProject: Failed to save {FileName}", projectFileName); + errorMessage = $"Error saving project: {ex.Message}"; + return false; + } + } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/ProjectFileHandlerService/ProjectLoadOutcome.cs b/src/LogExpert.UI/Services/ProjectFileHandlerService/ProjectLoadOutcome.cs new file mode 100644 index 00000000..d1e352e7 --- /dev/null +++ b/src/LogExpert.UI/Services/ProjectFileHandlerService/ProjectLoadOutcome.cs @@ -0,0 +1,26 @@ +using LogExpert.Core.Classes.Persister; + +namespace LogExpert.UI.Services.ProjectFileHandlerService; + +internal sealed class ProjectLoadOutcome +{ + public enum LoadStatus + { + Success, + NeedsIntervention, + Error, + EmptyProject + } + + public required LoadStatus Status { get; init; } + + public ProjectData? ProjectData { get; init; } + + public ProjectValidationResult? ValidationResult { get; init; } + + public string? LayoutXml { get; init; } + + public bool HasLayoutData => !string.IsNullOrEmpty(LayoutXml); + + public string? ErrorMessage { get; init; } +} \ No newline at end of file diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 332bbcc0..f0a0f07a 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-05-06 14:47:19 UTC + /// Generated: 2026-05-07 06:58:46 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "FD61116D4A97900F68D049775D265469B9C6B3202035EA8B66084AE1944D5CF3", + ["AutoColumnizer.dll"] = "DF22E41B38A18AAE704D35248357C32D8CC9476FE69364BFE009931E2EFA704A", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "5AEFECB027955EF4EB9274553F94081F17DD6457AAD57AF664AFFBD3932889CF", - ["CsvColumnizer.dll (x86)"] = "5AEFECB027955EF4EB9274553F94081F17DD6457AAD57AF664AFFBD3932889CF", - ["DefaultPlugins.dll"] = "0F7CE41008BBBD1BCDF4066C07D1F7D3B4DFEC9FFBF75CDFAE29E93E4EF994DC", - ["FlashIconHighlighter.dll"] = "C9889BE6722A38B9A58E7A1796985D68A24189A7D1EDAA7C5F929D85F8FC9BA7", - ["GlassfishColumnizer.dll"] = "AD4AE9B53F54D9E37B9F5C25FD8C1BF764FB58EE8DBF29842791552B7F414899", - ["JsonColumnizer.dll"] = "22777672274FD32FC36E4AEF7BA3EDBBC80C15B104E7E03F225AA21E485296A8", - ["JsonCompactColumnizer.dll"] = "8817DAFF4E5A574FC28D1099498E32BC7151C3FD5557BD9E8B955D633C2812A6", - ["Log4jXmlColumnizer.dll"] = "2A215605B7E093C83424AFCDA440BDD23C6732536B08AEA1EF081D1B540A48D4", - ["LogExpert.Core.dll"] = "AA2562DBEBC5508B119C79BB84D0F0B441704B15725E870F4802E8B319CBE613", - ["LogExpert.Resources.dll"] = "90CDAA06B3B2A9E7C521016EE3610FE3A0E8F71C0DC7BFE5DCEBC32BD866FE70", + ["CsvColumnizer.dll"] = "89D655FF1D95A41B71DFCF22772A5C2DD514D0D8688D825C2AB34D66AD7A5CCD", + ["CsvColumnizer.dll (x86)"] = "89D655FF1D95A41B71DFCF22772A5C2DD514D0D8688D825C2AB34D66AD7A5CCD", + ["DefaultPlugins.dll"] = "20D6D3D6F24992625285EBE2698B1E4EEB3018B8C0FE3A6221AA671ED90FAAF3", + ["FlashIconHighlighter.dll"] = "53B698C98953F7D6396698270A5A05C7C80BD66ED6A44CE3EE0F07C277384E68", + ["GlassfishColumnizer.dll"] = "C5C19B0C4F68ED8363E2281B512D3FCCCEC7C7C6D3A4F31A20F13E40DADC2373", + ["JsonColumnizer.dll"] = "F0E9425822F890544198B5A016A1E325F4F38F81DAA7F026DFD8169D51B6E9AA", + ["JsonCompactColumnizer.dll"] = "B621954BC540B4FF6B39EA15A7130EEBF357B5EE1747D74E495D9D5F5C59C928", + ["Log4jXmlColumnizer.dll"] = "DB20826D20DF0827422E1E24EBF117CFCEB26F964B194FF065F3B31EA9BDDB69", + ["LogExpert.Core.dll"] = "2567E08691D61D95BDDFF8326D922480BC2A6CB71A7B482FE2AD9E76D764CDDD", + ["LogExpert.Resources.dll"] = "016630BC957D4B63CD6B06C7C6B64F68F2BDC8A355C9357BBCF7E28BEAD89AA3", ["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"] = "237C573356EA01DAA956375E9E9D6EA477C45BCB81E3F1E7035595AEBA558B7D", - ["SftpFileSystem.dll"] = "B34DC1E5CE32D6C5D0E4522A358471D451B455835D318400327122139601BA6E", - ["SftpFileSystem.dll (x86)"] = "AB972F0E77DA7706214B042908AF40BE3BBB3EA12123AD76946EA224061E8C27", - ["SftpFileSystem.Resources.dll"] = "D7F1419A2F25058D937309DECC8A02615F2A150B219A75D51667001124A32075", - ["SftpFileSystem.Resources.dll (x86)"] = "D7F1419A2F25058D937309DECC8A02615F2A150B219A75D51667001124A32075", + ["RegexColumnizer.dll"] = "1479ECA203FAEF95B4FC68484BE712C4B21448CE56E6BBC7BD0B709D672C3770", + ["SftpFileSystem.dll"] = "16027F1015B57E89E0A9EB9E7427813FC0528A81BA9F98E233E932E30F06310E", + ["SftpFileSystem.dll (x86)"] = "E9EDB8FBED9DE17C3C3B1417C54ABBEF6B590C0AABC881C15D5F0D4157C1DC11", + ["SftpFileSystem.Resources.dll"] = "5E9E3C2251A932E34C2106502E0386CB927D4DB8C9E2F9D994E2DC1ED7C757CC", + ["SftpFileSystem.Resources.dll (x86)"] = "5E9E3C2251A932E34C2106502E0386CB927D4DB8C9E2F9D994E2DC1ED7C757CC", }; }