diff --git a/src/LogExpert.Core/Classes/Log/LogBuffer.cs b/src/LogExpert.Core/Classes/Log/LogBuffer.cs index 4c4f5578..a240720b 100644 --- a/src/LogExpert.Core/Classes/Log/LogBuffer.cs +++ b/src/LogExpert.Core/Classes/Log/LogBuffer.cs @@ -103,8 +103,20 @@ public void AddLine (LogLine lineMemory, long filePos) public void ClearLines () { - Array.Clear(_lineArray, 0, LineCount); + if (_lineArray == null) + { + _lineArray = ArrayPool.Shared.Rent(MAX_LINES); + _lineArrayLength = _lineArray.Length; + } + else + { + Array.Clear(_lineArray, 0, LineCount); + } + LineCount = 0; +#if DEBUG + _filePositions.Clear(); +#endif } /// @@ -129,6 +141,31 @@ public void Reinitialise (ILogFileInfo fileInfo, int maxLines) #endif } + /// + /// Evicts the buffer content to free memory while preserving metadata (LineCount, StartLine, StartPos, Size). + /// The buffer remains findable in buffer list lookups and can be re-read from disk when accessed. + /// + public void EvictContent () + { + if (_lineArray != null) + { + Array.Clear(_lineArray, 0, LineCount); + ArrayPool.Shared.Return(_lineArray); + _lineArray = null; + } + + // Do NOT zero LineCount — it is needed for buffer lookup in GetBufferForLineWithIndex. + // Do NOT zero StartLine, StartPos, Size — they are needed for re-reading from disk. + IsDisposed = true; +#if DEBUG + DisposeCount++; +#endif + } + + /// + /// Fully disposes the buffer content and resets all metadata. Used when the buffer is being returned to the pool + /// or completely removed from the buffer list. + /// public void DisposeContent () { if (_lineArray != null) diff --git a/src/LogExpert.Core/Classes/Log/LogfileReader.cs b/src/LogExpert.Core/Classes/Log/LogfileReader.cs index be8df3ff..bad4bfdf 100644 --- a/src/LogExpert.Core/Classes/Log/LogfileReader.cs +++ b/src/LogExpert.Core/Classes/Log/LogfileReader.cs @@ -593,13 +593,13 @@ public async Task GetLogLineMemoryWithWait (int lineNum) else { _isFastFailOnGetLogLine = true; - _logger.Debug(CultureInfo.InvariantCulture, "No result after {0}ms. Returning .", WAIT_TIME); + _logger.Info(CultureInfo.InvariantCulture, "Entering fast-fail mode for line {0}. No result after {1}ms.", lineNum, WAIT_TIME); } } } else { - _logger.Debug(CultureInfo.InvariantCulture, "Fast failing GetLogLine()"); + _logger.Info(CultureInfo.InvariantCulture, "Fast-fail returning null for line {0}", lineNum); if (!_isFailModeCheckCallPending) { _isFailModeCheckCallPending = true; @@ -1590,7 +1590,10 @@ private void GarbageCollectLruCache () try { removed.LogBuffer.AcquireContentLock(ref lockTaken); - _bufferPool.Return(removed.LogBuffer); + // Evict content but preserve metadata (LineCount, StartLine, etc.) + // so the buffer remains findable in _bufferList lookups. + // Do NOT return to pool — the buffer is still referenced by _bufferList. + removed.LogBuffer.EvictContent(); } finally { diff --git a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs b/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs index 14f32a3d..41958b4d 100644 --- a/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs +++ b/src/LogExpert.Core/Classes/Log/PositionAwareStreamReaderSystem.cs @@ -20,8 +20,6 @@ public class PositionAwareStreamReaderSystem : PositionAwareStreamReaderBase, IL private int _newLineSequenceLength; - private string _currentLine; // Store current line for Memory access - public override bool IsDisposed { get; protected set; } #endregion @@ -85,7 +83,6 @@ public bool TryReadLine (out ReadOnlyMemory lineMemory) } // Store line for Memory access - _currentLine = line; lineMemory = line.AsMemory(); return true; } @@ -100,7 +97,6 @@ public bool TryReadLine (out ReadOnlyMemory lineMemory) public void ReturnMemory (ReadOnlyMemory memory) { // No-op for System reader - string is already managed by GC - _currentLine = null; } #endregion diff --git a/src/LogExpert.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.Tests/Services/FileOperationServiceTests.cs new file mode 100644 index 00000000..114a0c83 --- /dev/null +++ b/src/LogExpert.Tests/Services/FileOperationServiceTests.cs @@ -0,0 +1,973 @@ +using System.Runtime.Versioning; +using System.Text; + +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Config; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Interface; +using LogExpert.UI.Services.FileOperationService; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Services; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[SupportedOSPlatform("windows")] +internal class FileOperationServiceTests : IDisposable +{ + private Mock _configManagerMock; + private Mock _tabControllerMock; + private Mock _ledServiceMock; + private Mock _pluginRegistryMock; + private Settings _settings; + + private List<(FileTabRequest Request, EncodingOptions Encoding)> _factoryCalls; + private LogWindow _stubLogWindow; + private Func _factory; + + private string? _clipboardText; + private List<(string FileName, bool IsSingleFileProject)> _projectCallbackCalls; + + private FileOperationService _sut; + + private bool _disposed; + + [SetUp] + public void Setup () + { + _configManagerMock = new Mock(); + _tabControllerMock = new Mock(); + _ledServiceMock = new Mock(); + _pluginRegistryMock = new Mock(); + + _settings = new Settings(); + _ = _configManagerMock.Setup(cm => cm.Settings).Returns(_settings); + _ = _pluginRegistryMock.Setup(pr => pr.RegisteredColumnizers).Returns([]); + + _factoryCalls = []; + _projectCallbackCalls = []; + _clipboardText = null; + + var coordinatorMock = new Mock(); + _stubLogWindow = new LogWindow(coordinatorMock.Object, "stub.log", false, false, _configManagerMock.Object); + + _factory = (request, encoding) => + { + _factoryCalls.Add((request, encoding)); + return _stubLogWindow; + }; + + // No existing windows by default + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + _sut = new FileOperationService( + _configManagerMock.Object, + _tabControllerMock.Object, + _ledServiceMock.Object, + _pluginRegistryMock.Object, + _factory, + () => _clipboardText, + (fileName, isSingle) => _projectCallbackCalls.Add((fileName, isSingle))); + } + + [TearDown] + public void TearDown () + { + _stubLogWindow?.Dispose(); + } + + [Test] + public void AddFileTab_NewFile_InvokesFactory_ReturnsLogWindow () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "test.log" }; + + // Act + var result = _sut.AddFileTab(request); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log")); + } + + [Test] + public void AddFileTab_DuplicateFile_ActivatesExisting_DoesNotCallFactory () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns(_stubLogWindow); + + var request = new FileTabRequest { FileName = "test.log" }; + + // Act + var result = _sut.AddFileTab(request); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Is.Empty, "Factory should not be called for duplicate files"); + _tabControllerMock.Verify(tc => tc.ActivateWindow(_stubLogWindow), Times.Once); + } + + [Test] + public void AddFileTab_NonTempFile_AddsToHistory () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "test.log", IsTempFile = false }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert + _configManagerMock.Verify(cm => cm.AddToFileHistory("test.log"), Times.Once); + } + + [Test] + public void AddFileTab_TempFile_DoesNotAddToHistory () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "temp.log", IsTempFile = true }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert + _configManagerMock.Verify(cm => cm.AddToFileHistory(It.IsAny()), Times.Never); + } + + [Test] + public void AddFileTab_LxpSuffix_SetsForcedPersistenceFileName () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "session.lxp" }; + + // Act + var result = _sut.AddFileTab(request); + + // Assert + Assert.That(result.ForcedPersistenceFileName, Is.EqualTo("session.lxp")); + } + + [Test] + public void AddFileTab_RaisesFileOpenedEvent () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + FileOpenedEventArgs? receivedArgs = null; + _sut.FileOpened += (_, args) => receivedArgs = args; + + var request = new FileTabRequest { FileName = "test.log" }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert + Assert.That(receivedArgs, Is.Not.Null); + Assert.That(receivedArgs!.LogWindow, Is.SameAs(_stubLogWindow)); + Assert.That(receivedArgs.Request, Is.SameAs(request)); + Assert.That(receivedArgs.ResolvedFileName, Is.Not.Null.And.Not.Empty); + Assert.That(receivedArgs.EncodingOptions, Is.Not.Null); + } + + [Test] + public void AddFileTab_TempFile_SetsUnicodeEncoding () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "temp.log", IsTempFile = true }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Encoding.Encoding, Is.InstanceOf()); + } + + [Test] + public void AddFileTab_DuplicateNonTemp_StillAddsToHistory () + { + // Arrange — duplicate detected + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns(_stubLogWindow); + + var request = new FileTabRequest { FileName = "test.log", IsTempFile = false }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert — history is still updated even for duplicates + _configManagerMock.Verify(cm => cm.AddToFileHistory("test.log"), Times.Once); + } + + [Test] + public void AddToFileHistory_CallsConfigManager_RaisesEvent () + { + // Arrange + var eventRaised = false; + _sut.FileHistoryChanged += (_, _) => eventRaised = true; + + // Act + _sut.AddToFileHistory("test.log"); + + // Assert + _configManagerMock.Verify(cm => cm.AddToFileHistory("test.log"), Times.Once); + Assert.That(eventRaised, Is.True); + } + + [Test] + public void FindWindowForFile_DelegatesToTabController () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName("test.log")) + .Returns(_stubLogWindow); + + // Act + var result = _sut.FindWindowForFile("test.log"); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + _tabControllerMock.Verify(tc => tc.FindWindowByFileName("test.log"), Times.Once); + } + + [Test] + public void FindWindowForFile_NoMatch_ReturnsNull () + { + // Arrange + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + // Act + var result = _sut.FindWindowForFile("nonexistent.log"); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void AddFileTab_ValidDefaultEncoding_SetsDefaultEncoding () + { + // Arrange + _settings.Preferences.DefaultEncoding = "utf-8"; + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "test.log" }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert + Assert.That(_factoryCalls[0].Encoding.DefaultEncoding, Is.Not.Null); + Assert.That(_factoryCalls[0].Encoding.DefaultEncoding.WebName, Is.EqualTo("utf-8")); + } + + [Test] + public void AddFileTab_InvalidDefaultEncoding_DefaultEncodingRemainsNull () + { + // Arrange + _settings.Preferences.DefaultEncoding = "not-a-real-encoding-xxxxx"; + _ = _tabControllerMock + .Setup(tc => tc.FindWindowByFileName(It.IsAny())) + .Returns((LogWindow)null!); + + var request = new FileTabRequest { FileName = "test.log" }; + + // Act + _ = _sut.AddFileTab(request); + + // Assert — invalid encoding should be caught and DefaultEncoding left null + Assert.That(_factoryCalls[0].Encoding.DefaultEncoding, Is.Null); + } + + [Test] + public void AddFilterTab_CreatesTabWithTempFile () + { + // Arrange + var filterParams = new FilterParams { SearchText = "error" }; + var logWindowMock = new Mock(); + using var pipe = new FilterPipe(filterParams, logWindowMock.Object); + + // Act + var result = _sut.AddFilterTab(pipe, "Filter: error", null); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.IsTempFile, Is.True); + Assert.That(_factoryCalls[0].Request.Title, Is.EqualTo("Filter: error")); + } + + [Test] + public void AddFilterTab_WithSearchText_RaisesFileOpenedWithFilterPipe () + { + // Arrange + var filterParams = new FilterParams { SearchText = "error" }; + var logWindowMock = new Mock(); + using var pipe = new FilterPipe(filterParams, logWindowMock.Object); + + var fileOpenedArgs = new List(); + _sut.FileOpened += (_, args) => fileOpenedArgs.Add(args); + + // Act + _ = _sut.AddFilterTab(pipe, "Filter: error", null); + + // Assert — two FileOpened events: one from AddFileTab, one with FilterPipe + Assert.That(fileOpenedArgs, Has.Count.EqualTo(2)); + + // First event: from AddFileTab (no FilterPipe) + Assert.That(fileOpenedArgs[0].FilterPipe, Is.Null); + + // Second event: carries the FilterPipe + Assert.That(fileOpenedArgs[1].FilterPipe, Is.SameAs(pipe)); + Assert.That(fileOpenedArgs[1].LogWindow, Is.SameAs(_stubLogWindow)); + } + + [Test] + public void AddFilterTab_EmptySearchText_DoesNotRaiseSecondFileOpened () + { + // Arrange + var filterParams = new FilterParams { SearchText = "" }; + var logWindowMock = new Mock(); + using var pipe = new FilterPipe(filterParams, logWindowMock.Object); + + var fileOpenedArgs = new List(); + _sut.FileOpened += (_, args) => fileOpenedArgs.Add(args); + + // Act + _ = _sut.AddFilterTab(pipe, "Filter: empty", null); + + // Assert — only one event from AddFileTab, no second event for empty search + Assert.That(fileOpenedArgs, Has.Count.EqualTo(1)); + Assert.That(fileOpenedArgs[0].FilterPipe, Is.Null); + } + + [Test] + public void AddFilterTab_NullSearchText_DoesNotRaiseSecondFileOpened () + { + // Arrange + var filterParams = new FilterParams { SearchText = null }; + var logWindowMock = new Mock(); + using var pipe = new FilterPipe(filterParams, logWindowMock.Object); + + var fileOpenedArgs = new List(); + _sut.FileOpened += (_, args) => fileOpenedArgs.Add(args); + + // Act + _ = _sut.AddFilterTab(pipe, "Filter: null", null); + + // Assert + Assert.That(fileOpenedArgs, Has.Count.EqualTo(1)); + } + + [Test] + public void AddFilterTab_SetsFileNameFromPipe () + { + // Arrange + var filterParams = new FilterParams { SearchText = "warn" }; + var logWindowMock = new Mock(); + using var pipe = new FilterPipe(filterParams, logWindowMock.Object); + + // Act + _ = _sut.AddFilterTab(pipe, "Filter: warn", null); + + // Assert — request FileName should come from the pipe's temp file + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo(pipe.FileName)); + } + + [Test] + public void AddTempFileTab_CreatesTabWithIsTempFileTrue () + { + // Act + var result = _sut.AddTempFileTab("temp.log", "Temp Tab"); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.IsTempFile, Is.True); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("temp.log")); + Assert.That(_factoryCalls[0].Request.Title, Is.EqualTo("Temp Tab")); + } + + [Test] + public void AddTempFileTab_DoesNotAddToHistory () + { + // Act + _ = _sut.AddTempFileTab("temp.log", "Temp Tab"); + + // Assert — temp files must not be added to file history + _configManagerMock.Verify(cm => cm.AddToFileHistory(It.IsAny()), Times.Never); + } + + [Test] + public void LoadFilesWithOption_SingleFile_CallsAddFileTab () + { + // Arrange + var fileNames = new[] { "test.log" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, false); + + // Assert + Assert.That(decision, Is.EqualTo(MultiFileDecision.SingleFiles)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log")); + } + + [Test] + public void LoadFilesWithOption_SingleLxjFile_CallsProjectCallback () + { + // Arrange + var fileNames = new[] { "project.lxj" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, false); + + // Assert + Assert.That(decision, Is.EqualTo(MultiFileDecision.Cancel)); + Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1)); + Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo("project.lxj")); + Assert.That(_projectCallbackCalls[0].IsSingleFileProject, Is.True); + Assert.That(_factoryCalls, Is.Empty, "Should not call AddFileTab for .lxj files"); + } + + [Test] + public void LoadFilesWithOption_MultiFile_SingleFilesPreference_CallsAddFileTabs () + { + // Arrange + _settings.Preferences.MultiFileOption = MultiFileOption.SingleFiles; + var fileNames = new[] { "b.log", "a.log" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, false); + + // Assert + Assert.That(decision, Is.EqualTo(MultiFileDecision.SingleFiles)); + // AddFileTabs calls AddFileTab for each — 2 factory calls + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + } + + [Test] + public void LoadFilesWithOption_MultiFile_MultiFilePreference_CallsAddMultiFileTab () + { + // Arrange + _settings.Preferences.MultiFileOption = MultiFileOption.MultiFile; + var fileNames = new[] { "a.log", "b.log" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, false); + + // Assert + Assert.That(decision, Is.EqualTo(MultiFileDecision.MultiFile)); + // AddMultiFileTab calls the factory once + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + } + + [Test] + public void LoadFilesWithOption_AskPreference_ReturnsAskUser () + { + // Arrange + _settings.Preferences.MultiFileOption = MultiFileOption.Ask; + var fileNames = new[] { "a.log", "b.log" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, false); + + // Assert + Assert.That(decision, Is.EqualTo(MultiFileDecision.AskUser)); + Assert.That(_factoryCalls, Is.Empty, "Should not create any tabs when decision is AskUser"); + } + + [Test] + public void LoadFilesWithOption_InvertLogic_FlipsDecision () + { + // Arrange — preference is SingleFiles, invert should flip to MultiFile + _settings.Preferences.MultiFileOption = MultiFileOption.SingleFiles; + var fileNames = new[] { "a.log", "b.log" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, invertLogic: true); + + // Assert — inverted: SingleFiles → MultiFile + Assert.That(decision, Is.EqualTo(MultiFileDecision.MultiFile)); + } + + [Test] + public void LoadFilesWithOption_InvertLogic_MultiFileToSingleFiles () + { + // Arrange — preference is MultiFile, invert should flip to SingleFiles + _settings.Preferences.MultiFileOption = MultiFileOption.MultiFile; + var fileNames = new[] { "a.log", "b.log" }; + + // Act + var decision = _sut.LoadFilesWithOption(fileNames, invertLogic: true); + + // Assert — inverted: MultiFile → SingleFiles + Assert.That(decision, Is.EqualTo(MultiFileDecision.SingleFiles)); + } + + [Test] + public void LoadFilesWithOption_SortsFileNames () + { + // Arrange + _settings.Preferences.MultiFileOption = MultiFileOption.SingleFiles; + var fileNames = new[] { "c.log", "a.log", "b.log" }; + + // Act + _ = _sut.LoadFilesWithOption(fileNames, false); + + // Assert — files should be sorted; factory calls reflect sorted order + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("a.log")); + Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log")); + Assert.That(_factoryCalls[2].Request.FileName, Is.EqualTo("c.log")); + } + + [Test] + public void AddMultiFileTab_EmptyArray_ReturnsNull () + { + // Act + var result = _sut.AddMultiFileTab([]); + + // Assert + Assert.That(result, Is.Null); + Assert.That(_factoryCalls, Is.Empty); + } + + [Test] + public void AddMultiFileTab_CreatesWindow_AddsToHistory () + { + // Arrange + var fileNames = new[] { "first.log", "second.log" }; + + // Act + var result = _sut.AddMultiFileTab(fileNames); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + // The request FileName should be the last file in the array + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("second.log")); + // History should contain the first file + _configManagerMock.Verify(cm => cm.AddToFileHistory("first.log"), Times.Once); + } + + [Test] + public void AddMultiFileTab_RaisesFileOpened_WithMultiFileNames () + { + // Arrange + var fileNames = new[] { "a.log", "b.log" }; + FileOpenedEventArgs? receivedArgs = null; + _sut.FileOpened += (_, args) => receivedArgs = args; + + // Act + _ = _sut.AddMultiFileTab(fileNames); + + // Assert + Assert.That(receivedArgs, Is.Not.Null); + Assert.That(receivedArgs!.MultiFileNames, Is.EqualTo(fileNames)); + Assert.That(receivedArgs.EncodingOptions, Is.Not.Null); + } + + [Test] + public void AddFileTabs_SkipsEmptyStrings () + { + // Arrange + var fileNames = new[] { "a.log", "", "b.log", null! }; + + // Act + _sut.AddFileTabs(fileNames); + + // Assert — only non-empty names should trigger AddFileTab + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + } + + [Test] + public void AddFileTabs_RoutesLxjToProjectCallback () + { + // Arrange + var fileNames = new[] { "project.lxj" }; + + // Act + _sut.AddFileTabs(fileNames); + + // Assert + Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1)); + Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo("project.lxj")); + Assert.That(_projectCallbackCalls[0].IsSingleFileProject, Is.False); + Assert.That(_factoryCalls, Is.Empty); + } + + [Test] + public void AddFileTabs_RoutesNonLxjToAddFileTab () + { + // Arrange + var fileNames = new[] { "app.log", "server.log" }; + + // Act + _sut.AddFileTabs(fileNames); + + // Assert + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("app.log")); + Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("server.log")); + } + + [Test] + public void AddFileTabs_MixedLxjAndLog_RoutesSeparately () + { + // Arrange + var fileNames = new[] { "app.log", "project.lxj", "server.log" }; + + // Act + _sut.AddFileTabs(fileNames); + + // Assert + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1)); + } + + [Test] + public void PasteFromClipboard_NullClipboard_ReturnsNull () + { + // Arrange + _clipboardText = null; + + // Act + var result = _sut.PasteFromClipboard(); + + // Assert + Assert.That(result, Is.Null); + Assert.That(_factoryCalls, Is.Empty); + } + + [Test] + public void PasteFromClipboard_EmptyClipboard_ReturnsNull () + { + // Arrange + _clipboardText = ""; + + // Act + var result = _sut.PasteFromClipboard(); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void PasteFromClipboard_CreatesFileAndTab () + { + // Arrange + _clipboardText = "line1\nline2\nline3"; + + // Act + var result = _sut.PasteFromClipboard(); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.IsTempFile, Is.True); + Assert.That(_factoryCalls[0].Request.Title, Is.EqualTo("Clipboard")); + + // Verify the temp file was actually created with content + var tempFileName = _factoryCalls[0].Request.FileName; + Assert.That(File.Exists(tempFileName), Is.True); + + var content = File.ReadAllText(tempFileName, Encoding.Unicode); + Assert.That(content, Is.EqualTo("line1\nline2\nline3")); + + // Cleanup temp file + File.Delete(tempFileName); + } + + [Test] + public void CanHandleDrop_FileDrop_ReturnsTrue () + { + // Arrange + var dataObjectMock = new Mock(); + _ = dataObjectMock.Setup(d => d.GetDataPresent(DataFormats.FileDrop)).Returns(true); + + // Act + var result = _sut.CanHandleDrop(dataObjectMock.Object); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void CanHandleDrop_NonFileDrop_ReturnsFalse () + { + // Arrange + var dataObjectMock = new Mock(); + _ = dataObjectMock.Setup(d => d.GetDataPresent(DataFormats.FileDrop)).Returns(false); + + // Act + var result = _sut.CanHandleDrop(dataObjectMock.Object); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void CanHandleDrop_NullData_ReturnsFalse () + { + // Act + var result = _sut.CanHandleDrop(null!); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void LoadStartupFiles_WithStartupFiles_LoadsStartupFiles_IgnoresLastOpen () + { + // Arrange + var lastOpenFiles = new List { "old1.log", "old2.log" }; + var startupFiles = new[] { "startup.log" }; + + // Act + _sut.LoadStartupFiles(lastOpenFiles, startupFiles); + + // Assert — startup files are loaded (via LoadFilesWithOption → AddFileTab) + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("startup.log")); + + // Last-open files should NOT be loaded + _configManagerMock.Verify(cm => cm.ClearLastOpenFilesList(), Times.Never); + } + + [Test] + public void LoadStartupFiles_NoStartupFiles_OpenLastFilesEnabled_LoadsLastOpenFiles () + { + // Arrange + _settings.Preferences.OpenLastFiles = true; + var lastOpenFiles = new List { "file1.log", "file2.log" }; + + // Act + _sut.LoadStartupFiles(lastOpenFiles, null); + + // Assert + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("file1.log")); + Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("file2.log")); + _configManagerMock.Verify(cm => cm.ClearLastOpenFilesList(), Times.Once); + } + + [Test] + public void LoadStartupFiles_NoStartupFiles_OpenLastFilesDisabled_DoesNothing () + { + // Arrange + _settings.Preferences.OpenLastFiles = false; + var lastOpenFiles = new List { "file1.log" }; + + // Act + _sut.LoadStartupFiles(lastOpenFiles, null); + + // Assert + Assert.That(_factoryCalls, Is.Empty); + _configManagerMock.Verify(cm => cm.ClearLastOpenFilesList(), Times.Never); + } + + [Test] + public void LoadStartupFiles_EmptyStartupArray_TreatsAsNoStartupFiles () + { + // Arrange + _settings.Preferences.OpenLastFiles = true; + var lastOpenFiles = new List { "file1.log" }; + var startupFiles = Array.Empty(); + + // Act + _sut.LoadStartupFiles(lastOpenFiles, startupFiles); + + // Assert — empty startup array should fall through to last-open-files path + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("file1.log")); + _configManagerMock.Verify(cm => cm.ClearLastOpenFilesList(), Times.Once); + } + + [Test] + public void LoadStartupFiles_ClearsLastOpenFilesAfterLoading () + { + // Arrange + _settings.Preferences.OpenLastFiles = true; + var lastOpenFiles = new List { "file1.log" }; + + // Act + _sut.LoadStartupFiles(lastOpenFiles, null); + + // Assert + _configManagerMock.Verify(cm => cm.ClearLastOpenFilesList(), Times.Once); + } + + [Test] + public void LoadStartupFiles_SkipsEmptyNamesInLastOpenFiles () + { + // Arrange + _settings.Preferences.OpenLastFiles = true; + var lastOpenFiles = new List { "file1.log", "", "file2.log", null! }; + + // Act + _sut.LoadStartupFiles(lastOpenFiles, null); + + // Assert — only non-empty names should trigger AddFileTab + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + } + + [Test] + public void SaveLastOpenFilesList_SkipsTempFiles () + { + // Arrange + var coordinatorMock = new Mock(); + using var tempWindow = new LogWindow(coordinatorMock.Object, "temp.log", true, false, _configManagerMock.Object); + tempWindow.GivenFileName = "temp.log"; + + _ = _tabControllerMock + .Setup(tc => tc.GetAllWindowsFromDockPanel()) + .Returns(new List { tempWindow }.AsReadOnly()); + + // Act + _sut.SaveLastOpenFilesList(); + + // Assert — temp files should be skipped + Assert.That(_settings.LastOpenFilesList, Is.Empty); + } + + [Test] + public void SaveLastOpenFilesList_AddsGivenFileNameToConfig () + { + // Arrange + var coordinatorMock = new Mock(); + using var normalWindow = new LogWindow(coordinatorMock.Object, "app.log", false, false, _configManagerMock.Object); + normalWindow.GivenFileName = "app.log"; + + _ = _tabControllerMock + .Setup(tc => tc.GetAllWindowsFromDockPanel()) + .Returns(new List { normalWindow }.AsReadOnly()); + + // Act + _sut.SaveLastOpenFilesList(); + + // Assert + Assert.That(_settings.LastOpenFilesList, Has.Count.EqualTo(1)); + Assert.That(_settings.LastOpenFilesList[0], Is.EqualTo("app.log")); + } + + [Test] + public void SaveLastOpenFilesList_MultipleMixed_OnlySavesNonTemp () + { + // Arrange + var coordinatorMock = new Mock(); + using var normalWindow = new LogWindow(coordinatorMock.Object, "app.log", false, false, _configManagerMock.Object); + normalWindow.GivenFileName = "app.log"; + using var tempWindow = new LogWindow(coordinatorMock.Object, "filter.tmp", true, false, _configManagerMock.Object); + tempWindow.GivenFileName = "filter.tmp"; + using var normalWindow2 = new LogWindow(coordinatorMock.Object, "server.log", false, false, _configManagerMock.Object); + normalWindow2.GivenFileName = "server.log"; + + _ = _tabControllerMock + .Setup(tc => tc.GetAllWindowsFromDockPanel()) + .Returns(new List { normalWindow, tempWindow, normalWindow2 }.AsReadOnly()); + + // Act + _sut.SaveLastOpenFilesList(); + + // Assert + Assert.That(_settings.LastOpenFilesList, Has.Count.EqualTo(2)); + Assert.That(_settings.LastOpenFilesList, Does.Contain("app.log")); + Assert.That(_settings.LastOpenFilesList, Does.Contain("server.log")); + } + + [Test] + public void AddFileTabDeferred_SetsDoNotAddToDockPanelTrue () + { + // Act + var result = _sut.AddFileTabDeferred("deferred.log", false, "Deferred", true, null); + + // Assert + Assert.That(result, Is.SameAs(_stubLogWindow)); + Assert.That(_factoryCalls, Has.Count.EqualTo(1)); + Assert.That(_factoryCalls[0].Request.DoNotAddToDockPanel, Is.True); + Assert.That(_factoryCalls[0].Request.ForcePersistenceLoading, Is.True); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("deferred.log")); + Assert.That(_factoryCalls[0].Request.Title, Is.EqualTo("Deferred")); + } + + [Test] + public void AddFileTabDeferred_TempFile_SetsIsTempFileTrue () + { + // Act + _ = _sut.AddFileTabDeferred("temp.log", true, "Temp", false, null); + + // Assert + Assert.That(_factoryCalls[0].Request.IsTempFile, Is.True); + } + + [Test] + public void LoadFiles_DelegatesToAddFileTabs () + { + // Arrange + var fileNames = new[] { "a.log", "b.log" }; + + // Act + _sut.LoadFiles(fileNames); + + // Assert — LoadFiles just calls AddFileTabs + Assert.That(_factoryCalls, Has.Count.EqualTo(2)); + Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("a.log")); + Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log")); + } + + 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.Tests/Services/LogWindowCoordinatorTests.cs b/src/LogExpert.Tests/Services/LogWindowCoordinatorTests.cs index f095551f..d3bb7b7a 100644 --- a/src/LogExpert.Tests/Services/LogWindowCoordinatorTests.cs +++ b/src/LogExpert.Tests/Services/LogWindowCoordinatorTests.cs @@ -22,6 +22,7 @@ public class LogWindowCoordinatorTests private LogWindowCoordinator _coordinator; private Mock _tabControllerMock; private Mock _ledServiceMock; + private Mock _fileOperationServiceMock; private Settings _settings; private Preferences _preferences; @@ -32,20 +33,19 @@ public void Setup () _pluginRegistryMock = new Mock(); _tabControllerMock = new Mock(); _ledServiceMock = new Mock(); + _fileOperationServiceMock = new Mock(); _settings = new Settings(); _preferences = _settings.Preferences; _ = _configManagerMock.Setup(cm => cm.Settings).Returns(_settings); _ = _pluginRegistryMock.Setup(pr => pr.RegisteredColumnizers).Returns([]); - // Tab creation methods (AddFilterTab, AddTempFileTab) are pure delegation - // to LogTabWindow and are verified via smoke tests rather than unit tests, - // as they require a full WinForms context. _coordinator = new LogWindowCoordinator( _configManagerMock.Object, _pluginRegistryMock.Object, null!, _tabControllerMock.Object, - _ledServiceMock.Object); + _ledServiceMock.Object, + _fileOperationServiceMock.Object); } [Test] diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index ee10dde3..238eba6e 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -6517,7 +6517,22 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi var line = _logFileReader.GetLogLineMemoryWithWait(rowIndex).Result; - if (line != null) + if (line == null) + { + _logger.Warn("CellPainting: null line for rowIndex={0}, isFilteredGridView={1}", rowIndex, isFilteredGridView); + + // Paint an empty cell with proper colors to prevent white-on-white default rendering + e.Graphics.SetClip(e.CellBounds); + using (var brush = new SolidBrush(e.CellStyle.BackColor)) + { + e.Graphics.FillRectangle(brush, e.CellBounds); + } + + e.Paint(e.CellBounds, DataGridViewPaintParts.Border); + e.Handled = true; + return; + } + { var entry = FindFirstNoWordMatchHighlightEntry(line); e.Graphics.SetClip(e.CellBounds); @@ -7580,17 +7595,6 @@ public void Reload () { LoadFilesAsMulti(_fileNames, EncodingOptions); } - - //if (currentLine < this.dataGridView.RowCount && currentLine >= 0) - // this.dataGridView.CurrentCell = this.dataGridView.Rows[currentLine].Cells[0]; - //if (firstDisplayedLine < this.dataGridView.RowCount && firstDisplayedLine >= 0) - // this.dataGridView.FirstDisplayedScrollingRowIndex = firstDisplayedLine; - - //if (this.filterTailCheckBox.Checked) - //{ - // _logger.logInfo("Refreshing filter view because of reload."); - // FilterSearch(); - //} } public void PreferencesChanged (string fontName, float fontSize, bool setLastColumnWidth, int lastColumnWidth, bool isLoadTime, SettingsFlags flags) diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index c3342509..579e3507 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -9,7 +9,6 @@ using LogExpert.Core.Classes; using LogExpert.Core.Classes.Columnizer; -using LogExpert.Core.Classes.Filter; using LogExpert.Core.Classes.Persister; using LogExpert.Core.Config; using LogExpert.Core.Entities; @@ -21,6 +20,7 @@ using LogExpert.UI.Entities; using LogExpert.UI.Extensions; using LogExpert.UI.Extensions.LogWindow; +using LogExpert.UI.Services.FileOperationService; using LogExpert.UI.Services.LedService; using LogExpert.UI.Services.LogWindowCoordinatorService; using LogExpert.UI.Services.MenuToolbarService; @@ -50,6 +50,7 @@ internal partial class LogTabWindow : Form, ILogTabWindow private readonly MenuToolbarController _menuToolbarController; private readonly LogWindowCoordinator _logWindowCoordinator; private readonly ToolWindowCoordinator _toolWindowCoordinator; + private readonly FileOperationService _fileOperationService; private bool _disposed; @@ -104,7 +105,12 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu _deadIcon = _ledService.GetDeadIcon(); - _logWindowCoordinator = new LogWindowCoordinator(configManager, PluginRegistry.PluginRegistry.Instance, this, _tabController, _ledService); + _fileOperationService = new FileOperationService(configManager, _tabController, _ledService, PluginRegistry.PluginRegistry.Instance, CreateLogWindowFromRequest, () => Clipboard.ContainsText() ? Clipboard.GetText() : null, LoadProject); + + _fileOperationService.FileHistoryChanged += (_, _) => FillHistoryMenu(); + _fileOperationService.FileOpened += OnFileOperationServiceFileOpened; + + _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 mainMenuStrip.Location = new Point(0, 0); @@ -150,6 +156,70 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu InitToolWindows(); } + [SupportedOSPlatform("windows")] + private LogWindow.LogWindow CreateLogWindowFromRequest (FileTabRequest request, EncodingOptions encodingOptions) + { + LogWindow.LogWindow logWindow = new( + _logWindowCoordinator, + PersisterHelpers.FindFilenameForSettings(request.FileName, PluginRegistry.PluginRegistry.Instance), + request.IsTempFile, + request.ForcePersistenceLoading, + ConfigManager) + { + GivenFileName = request.FileName + }; + + if (request.PreProcessColumnizer != null) + { + logWindow.ForceColumnizerForLoading(request.PreProcessColumnizer); + } + + if (request.IsTempFile) + { + logWindow.TempTitleName = request.Title ?? string.Empty; + } + + AddLogWindow(logWindow, request.Title, request.DoNotAddToDockPanel); + return logWindow; + } + + [SupportedOSPlatform("windows")] + private void OnFileOperationServiceFileOpened (object? sender, FileOpenedEventArgs e) + { + if (e.LogWindow.Tag is LogWindowData data) + { + data.Color = _defaultTabColor; + } + + if (!e.Request.IsTempFile) + { + SetTooltipText(e.LogWindow, e.ResolvedFileName); + } + + // Filter tooltip setup + if (e.FilterPipe != null && e.FilterPipe.FilterParams.SearchText?.Length > 0) + { + ToolTip tip = new(components); + var isInvertText = e.FilterPipe.FilterParams.IsInvert ? Resources.LogTabWindow_UI_LogWindow_ToolTip_InvertMatch : string.Empty; + var isColumnRestrictText = e.FilterPipe.FilterParams.ColumnRestrict ? Resources.LogTabWindow_UI_LogWindow_Tooltip_ColumnRestrict : string.Empty; + tip.SetToolTip(e.LogWindow, string.Format(CultureInfo.InvariantCulture, Resources.LogTabWindow_UI_LogWindow_ToolTip_Filter, e.FilterPipe.FilterParams.SearchText, isInvertText, isColumnRestrictText)); + tip.AutomaticDelay = 10; + tip.AutoPopDelay = 5000; + if (e.LogWindow.Tag is LogWindowData filterData) + { + filterData.ToolTip = tip; + } + } + + // Multi-file loading (used starting in Phase 4) + if (e.MultiFileNames != null && e.EncodingOptions != null) + { + multiFileToolStripMenuItem.Checked = true; + multiFileEnabledStripMenuItem.Checked = true; + _ = BeginInvoke(e.LogWindow.LoadFilesAsMulti, e.MultiFileNames, e.EncodingOptions); + } + } + private void InitializeMenuToolbarControllerEvents () { _menuToolbarController.HistoryItemClicked += OnMenuControllerHistoryItemClicked; @@ -170,7 +240,7 @@ private void OnMenuControllerHistoryItemRemoveRequested (object? sender, History private void OnMenuControllerHistoryItemClicked (object? sender, HistoryItemClickedEventArgs e) { - _ = AddFileTab(e.FileName, false, null, false, null); + _ = _fileOperationService.AddFileTab(new FileTabRequest { FileName = e.FileName }); } private void InitializeTabControllerEvents () @@ -213,19 +283,6 @@ public LogWindow.LogWindow CurrentLogWindow internal HighlightGroup FindHighlightGroup (string groupName) { return _logWindowCoordinator.ResolveHighlightGroup(groupName, null); - - //lock (HighlightGroupList) - //{ - // foreach (var group in HighlightGroupList) - // { - // if (group.GroupName.Equals(groupName, StringComparison.Ordinal)) - // { - // return group; - // } - // } - - // return null; - //} } #endregion @@ -235,7 +292,7 @@ internal HighlightGroup FindHighlightGroup (string groupName) [SupportedOSPlatform("windows")] public LogWindow.LogWindow AddTempFileTab (string fileName, string title) { - return AddFileTab(fileName, true, title, false, null); + return _fileOperationService.AddTempFileTab(fileName, title); } private void ConfigureDockPanel () @@ -461,129 +518,10 @@ private void ApplyToolTips () truncateFileToolStripMenuItem.ToolTipText = Resources.LogTabWindow_UI_ToolStripMenuItem_ToolTip_truncateFileToolStripMenuItem; } - [SupportedOSPlatform("windows")] - public LogWindow.LogWindow AddFilterTab (FilterPipe pipe, string title, ILogLineMemoryColumnizer preProcessColumnizer) - { - var logWin = AddFileTab(pipe.FileName, true, title, false, preProcessColumnizer); - if (pipe.FilterParams.SearchText?.Length > 0) - { - ToolTip tip = new(components); - - //Resources.LogTabWindow_UI_LogWindow_ToolTip_Filter - var isInvertText = pipe.FilterParams.IsInvert ? Resources.LogTabWindow_UI_LogWindow_ToolTip_InvertMatch : string.Empty; - var isColumnRestrictText = pipe.FilterParams.ColumnRestrict ? Resources.LogTabWindow_UI_LogWindow_Tooltip_ColumnRestrict : string.Empty; - tip.SetToolTip(logWin, string.Format(CultureInfo.InvariantCulture, Resources.LogTabWindow_UI_LogWindow_ToolTip_Filter, pipe.FilterParams.SearchText, isInvertText, isColumnRestrictText)); - tip.AutomaticDelay = 10; - tip.AutoPopDelay = 5000; - var data = logWin.Tag as LogWindowData; - data.ToolTip = tip; - } - - return logWin; - } - - [SupportedOSPlatform("windows")] - public LogWindow.LogWindow AddFileTabDeferred (string givenFileName, bool isTempFile, string title, bool forcePersistenceLoading, ILogLineMemoryColumnizer preProcessColumnizer) - { - return AddFileTab(givenFileName, isTempFile, title, forcePersistenceLoading, preProcessColumnizer, true); - } - - [SupportedOSPlatform("windows")] - public LogWindow.LogWindow AddFileTab (string givenFileName, bool isTempFile, string title, bool forcePersistenceLoading, ILogLineMemoryColumnizer preProcessColumnizer, bool doNotAddToDockPanel = false) - { - var logFileName = PersisterHelpers.FindFilenameForSettings(givenFileName, PluginRegistry.PluginRegistry.Instance); - var win = FindWindowForFile(logFileName); - if (win != null) - { - if (!isTempFile) - { - AddToFileHistory(givenFileName); - } - - _logWindowCoordinator.SelectTab(win); - return win; - } - - EncodingOptions encodingOptions = new(); - FillDefaultEncodingFromSettings(encodingOptions); - LogWindow.LogWindow logWindow = new(_logWindowCoordinator, logFileName, isTempFile, forcePersistenceLoading, ConfigManager) - { - GivenFileName = givenFileName - }; - - if (preProcessColumnizer != null) - { - logWindow.ForceColumnizerForLoading(preProcessColumnizer); - } - - if (isTempFile) - { - logWindow.TempTitleName = title; - encodingOptions.Encoding = new UnicodeEncoding(false, false); - } - - AddLogWindow(logWindow, title, doNotAddToDockPanel); - if (!isTempFile) - { - AddToFileHistory(givenFileName); - } - - var data = logWindow.Tag as LogWindowData; - data.Color = _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) - { - SetTooltipText(logWindow, logFileName); - } - - if (givenFileName.EndsWith(".lxp", StringComparison.Ordinal)) - { - logWindow.ForcedPersistenceFileName = givenFileName; - } - - // this.BeginInvoke(new LoadFileDelegate(logWindow.LoadFile), new object[] { logFileName, encoding }); - _ = Task.Run(() => logWindow.LoadFile(logFileName, encodingOptions)); - return logWindow; - } - - [SupportedOSPlatform("windows")] - public LogWindow.LogWindow AddMultiFileTab (string[] fileNames) - { - if (fileNames.Length < 1) - { - return null; - } - - LogWindow.LogWindow logWindow = new(_logWindowCoordinator, fileNames[^1], false, false, ConfigManager); - AddLogWindow(logWindow, fileNames[^1], false); - multiFileToolStripMenuItem.Checked = true; - multiFileEnabledStripMenuItem.Checked = true; - EncodingOptions encodingOptions = new(); - FillDefaultEncodingFromSettings(encodingOptions); - _ = BeginInvoke(logWindow.LoadFilesAsMulti, fileNames, encodingOptions); - AddToFileHistory(fileNames[0]); - return logWindow; - } - [SupportedOSPlatform("windows")] public void LoadFiles (string[] fileNames) { - _ = Invoke(AddFileTabs, [fileNames]); + Invoke(() => _fileOperationService.AddFileTabs(fileNames)); } [SupportedOSPlatform("windows")] @@ -803,6 +741,10 @@ protected override void Dispose (bool disposing) _tabStringFormat?.Dispose(); _menuToolbarController?.Dispose(); _toolWindowCoordinator?.Dispose(); + // Dispose TabController after FileOperationService is no longer reachable. + // FileOperationService holds a reference to _tabController but does not own it; + // after Dispose(), no caller invokes the service, so stale references are harmless. + _tabController?.Dispose(); } _disposed = true; @@ -815,24 +757,10 @@ protected override void Dispose (bool disposing) [SupportedOSPlatform("windows")] private void PasteFromClipboard () { - if (Clipboard.ContainsText()) + var logWindow = _fileOperationService.PasteFromClipboard(); + if (logWindow?.Tag is LogWindowData) { - var text = Clipboard.GetText(); - var fileName = Path.GetTempFileName(); - - using (FileStream fStream = new(fileName, FileMode.Append, FileAccess.Write, FileShare.Read)) - using (StreamWriter writer = new(fStream, Encoding.Unicode)) - { - writer.Write(text); - writer.Close(); - } - - var title = Resources.LogTabWindow_UI_LogWindow_Title_Text_From_Clipboard; - var logWindow = AddTempFileTab(fileName, title); - if (logWindow.Tag is LogWindowData) - { - SetTooltipText(logWindow, string.Format(CultureInfo.InvariantCulture, Resources.LogTabWindow_UI_LogWindow_Title_ToolTip_PastedOn, DateTime.Now)); - } + SetTooltipText(logWindow, string.Format(CultureInfo.InvariantCulture, Resources.LogTabWindow_UI_LogWindow_Title_ToolTip_PastedOn, DateTime.Now)); } } @@ -848,17 +776,6 @@ private void DestroyBookmarkWindow () _toolWindowCoordinator.Destroy(); } - private void SaveLastOpenFilesList () - { - foreach (var logWin in _tabController.GetAllWindowsFromDockPanel()) - { - if (!logWin.IsTempFile) - { - ConfigManager.Settings.LastOpenFilesList.Add(logWin.GivenFileName); - } - } - } - [SupportedOSPlatform("windows")] private void SaveWindowPosition () { @@ -884,44 +801,6 @@ private static void SetTooltipText (LogWindow.LogWindow logWindow, string logFil logWindow.ToolTipText = logFileName; } - private void FillDefaultEncodingFromSettings (EncodingOptions encodingOptions) - { - if (ConfigManager.Settings.Preferences.DefaultEncoding != null) - { - try - { - encodingOptions.DefaultEncoding = Encoding.GetEncoding(ConfigManager.Settings.Preferences.DefaultEncoding); - } - catch (ArgumentException) - { - //ConfigManager.Settings.Preferences.DefaultEncoding - _logger.Warn($"### FillDefaultEncodingFromSettings: Encoding {ConfigManager.Settings.Preferences.DefaultEncoding} is not a valid encoding"); - encodingOptions.DefaultEncoding = null; - } - } - } - - [SupportedOSPlatform("windows")] - private void AddFileTabs (string[] fileNames) - { - foreach (var fileName in fileNames) - { - if (!string.IsNullOrEmpty(fileName)) - { - if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)) - { - LoadProject(fileName, false); - } - else - { - _ = AddFileTab(fileName, false, null, false, null); - } - } - } - - Activate(); - } - /// /// Adds a LogWindow to the tab system. Sets up window properties, delegates to TabController, and performs /// additional setup. @@ -967,24 +846,6 @@ private void DisconnectEventHandlers (LogWindow.LogWindow logWindow) logWindow.SyncModeChanged -= OnLogWindowSyncModeChanged; } - [SupportedOSPlatform("windows")] - private void AddToFileHistory (string fileName) - { - ConfigManager.AddToFileHistory(fileName); - FillHistoryMenu(); - } - - /// - /// Finds an existing window for a file. - /// - /// File name to search for - /// The LogWindow for the file, or null if not found - [SupportedOSPlatform("windows")] - private LogWindow.LogWindow FindWindowForFile (string fileName) - { - return _tabController.FindWindowByFileName(fileName); - } - [SupportedOSPlatform("windows")] private void FillHistoryMenu () { @@ -1084,65 +945,25 @@ private void OpenFileDialog () if (info.Exists) { - LoadFiles(openFileDialog.FileNames, false); - } - } - } - - [SupportedOSPlatform("windows")] - private void LoadFiles (string[] names, bool invertLogic) - { - Array.Sort(names); - - if (names.Length == 1) - { - if (names[0].EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)) - { - LoadProject(names[0], true); - return; - } - - _ = AddFileTab(names[0], false, null, false, null); - return; - } - - var option = ConfigManager.Settings.Preferences.MultiFileOption; - if (option == MultiFileOption.Ask) - { - MultiLoadRequestDialog dlg = new(); - var res = dlg.ShowDialog(); + var decision = _fileOperationService.LoadFilesWithOption(openFileDialog.FileNames, false); + if (decision == MultiFileDecision.AskUser) + { + MultiLoadRequestDialog dlg = new(); + var res = dlg.ShowDialog(); + var sortedNames = openFileDialog.FileNames; + Array.Sort(sortedNames); - if (res == DialogResult.Yes) - { - option = MultiFileOption.SingleFiles; - } - else if (res == DialogResult.No) - { - option = MultiFileOption.MultiFile; - } - else - { - return; - } - } - else - { - if (invertLogic) - { - option = option == MultiFileOption.SingleFiles - ? MultiFileOption.MultiFile - : MultiFileOption.SingleFiles; + if (res == DialogResult.Yes) + { + _fileOperationService.AddFileTabs(sortedNames); + } + else if (res == DialogResult.No) + { + _ = _fileOperationService.AddMultiFileTab(sortedNames); + } + } } } - - if (option == MultiFileOption.SingleFiles) - { - AddFileTabs(names); - } - else - { - _ = AddMultiFileTab(names); - } } private void SetColumnizerHistoryEntry (string fileName, ILogLineMemoryColumnizer columnizer) @@ -1276,12 +1097,13 @@ private void ProgressBarUpdateWorker (ProgressEventArgs e) } [SupportedOSPlatform("windows")] - //TODO Crossthread Exception when a log file has been filtered to a new tab! private void StatusLineEventWorker (StatusLineEventArgs e) { if (e != null) { - //_logger.logDebug("StatusLineEvent: text = " + e.StatusText); +#if DEBUG + _logger.Debug("StatusLineEvent: text = " + e.StatusText); +#endif labelStatus.Text = e.StatusText; labelStatus.Size = TextRenderer.MeasureText(labelStatus.Text, labelStatus.Font); labelLines.Text = $"{e.LineCount} {Resources.LogTabWindow_StatusLineText_lowerCase_Lines}"; @@ -1679,8 +1501,8 @@ private void LoadProject (string projectFileName, bool restoreLayout) foreach (var fileName in projectData.FileNames) { _ = hasLayoutData - ? AddFileTabDeferred(fileName, false, null, true, null) - : AddFileTab(fileName, false, null, true, null); + ? _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 @@ -1834,7 +1656,7 @@ private IDockContent DeserializeDockContent (string persistString) if (persistString.StartsWith(WindowTypes.LogWindow.ToString(), StringComparison.OrdinalIgnoreCase)) { var fileName = persistString[(WindowTypes.LogWindow.ToString().Length + 1)..]; - var win = FindWindowForFile(fileName); + var win = _fileOperationService.FindWindowForFile(fileName); if (win != null) { return win; @@ -1870,25 +1692,8 @@ private void OnLogTabWindowLoad (object sender, EventArgs e) } } - if (ConfigManager.Settings.Preferences.OpenLastFiles && _startupFileNames == null) - { - var tmpList = ObjectClone.Clone(ConfigManager.Settings.LastOpenFilesList); - - foreach (var name in tmpList) - { - if (!string.IsNullOrEmpty(name)) - { - AddFileTab(name, false, null, false, null); - } - } - - ConfigManager.ClearLastOpenFilesList(); - } - - if (_startupFileNames != null) - { - LoadFiles(_startupFileNames, false); - } + var lastOpenFiles = ObjectClone.Clone(ConfigManager.Settings.LastOpenFilesList); + _fileOperationService.LoadStartupFiles(lastOpenFiles, _startupFileNames); FillHighlightComboBox(); FillToolLauncherBar(); @@ -1905,7 +1710,7 @@ private void OnLogTabWindowFormClosing (object sender, CancelEventArgs e) { IList deleteLogWindowList = []; ConfigManager.Settings.AlwaysOnTop = TopMost && ConfigManager.Settings.Preferences.AllowOnlyOneInstance; - SaveLastOpenFilesList(); + _fileOperationService.SaveLastOpenFilesList(); foreach (var logWindow in _tabController.GetAllWindows()) { @@ -2059,9 +1864,9 @@ private void OnLogTabWindowDragEnter (object sender, DragEventArgs e) private void OnLogWindowDragOver (object sender, DragEventArgs e) { - e.Effect = !e.Data.GetDataPresent(DataFormats.FileDrop) - ? DragDropEffects.None - : DragDropEffects.Copy; + e.Effect = _fileOperationService.CanHandleDrop(e.Data) + ? DragDropEffects.Copy + : DragDropEffects.None; } private void OnLogWindowDragDrop (object sender, DragEventArgs e) @@ -2079,15 +1884,28 @@ private void OnLogWindowDragDrop (object sender, DragEventArgs e) _logger.Debug(s); #endif - if (e.Data.GetDataPresent(DataFormats.FileDrop)) + if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] names) { - var o = e.Data.GetData(DataFormats.FileDrop); - if (o is string[] names) + // (shift pressed) https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.drageventargs.keystate + var invertLogic = (e.KeyState & 4) == 4; + var decision = _fileOperationService.LoadFilesWithOption(names, invertLogic); + + if (decision == MultiFileDecision.AskUser) { - // (shift pressed) https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.drageventargs.keystate - LoadFiles(names, (e.KeyState & 4) == 4); - e.Effect = DragDropEffects.Copy; + MultiLoadRequestDialog dlg = new(); + var res = dlg.ShowDialog(); + + if (res == DialogResult.Yes) + { + _fileOperationService.AddFileTabs(names); + } + else if (res == DialogResult.No) + { + _ = _fileOperationService.AddMultiFileTab(names); + } } + + e.Effect = DragDropEffects.Copy; } } @@ -2812,7 +2630,7 @@ private void OnOpenURIToolStripMenuItemClick (object sender, EventArgs e) { ConfigManager.Settings.UriHistoryList = dlg.UriHistory; ConfigManager.Save(SettingsFlags.FileHistory); - LoadFiles([dlg.Uri], false); + _fileOperationService.LoadFilesWithOption(new[] { dlg.Uri }, false); } } } diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs index a4de7368..35bd457e 100644 --- a/src/LogExpert.UI/Entities/PaintHelper.cs +++ b/src/LogExpert.UI/Entities/PaintHelper.cs @@ -217,9 +217,9 @@ private static void AutoResizeColumns (BufferedDataGridView gridView, bool setLa } /// - /// This returns Black or White based on the color that is given - /// If the color is smaller than 128 it means its a darker color and white should be the fore color, - /// if the color is bigger than 128 it means its a lighter color and black should be the fore color + /// This returns Black or White based on the color that is given If the color is smaller than 128 it means its a + /// darker color and white should be the fore color, if the color is bigger than 128 it means its a lighter color + /// and black should be the fore color /// /// lighter or darker back color /// White or Black based on the given back color @@ -446,14 +446,17 @@ private static void PaintHighlightedCell (ILogPaintContextUI logPaintCtx, DataGr } /// - /// Builds a list of HilightMatchEntry objects. A HilightMatchEntry spans over a region that is painted with the same foreground and - /// background colors. - /// All regions which don't match a word-mode entry will be painted with the colors of a default entry (groundEntry). This is either the - /// first matching non-word-mode highlight entry or a black-on-white default (if no matching entry was found). + /// Builds a list of HilightMatchEntry objects. A HilightMatchEntry spans over a region that is painted with the + /// same foreground and background colors. All regions which don't match a word-mode entry will be painted with the + /// colors of a default entry (groundEntry). This is either the first matching non-word-mode highlight entry or a + /// black-on-white default (if no matching entry was found). /// /// List of all highlight matches for the current cell /// The entry that is used as the default. - /// List of HilightMatchEntry objects. The list spans over the whole cell and contains color infos for every substring. + /// + /// List of HilightMatchEntry objects. The list spans over the whole cell and contains color infos for every + /// substring. + /// private static IList MergeHighlightMatchEntries (IList matchList, HighlightMatchEntry groundEntry) { // Fill an area with lenth of whole text with a default hilight entry diff --git a/src/LogExpert.UI/Interface/IFileOperationService.cs b/src/LogExpert.UI/Interface/IFileOperationService.cs new file mode 100644 index 00000000..ec64a34f --- /dev/null +++ b/src/LogExpert.UI/Interface/IFileOperationService.cs @@ -0,0 +1,112 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Filter; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Services.FileOperationService; + +namespace LogExpert.UI.Interface; + +/// +/// Application-level Facade that orchestrates all file-loading coordination: +/// determining how to load files, checking for duplicates, managing file history, +/// resolving default encoding, creating LogWindows via an injected factory, +/// and persisting last-open file lists. +/// +/// +/// +/// This interface intentionally covers multiple related responsibilities (tab creation, +/// history, multi-file decisions, clipboard, drag-drop, startup persistence) as a single +/// Facade. Splitting into smaller interfaces is deferred until usage patterns stabilize. +/// +/// +/// Thread affinity: All public methods must be invoked on the UI thread. +/// The service does not marshal calls internally. +/// +/// +internal interface IFileOperationService +{ + /// + /// Central file-tab creation method. Checks for duplicates, resolves encoding, + /// creates LogWindow via factory, adds to history, raises FileOpened. + /// + LogWindow AddFileTab (FileTabRequest request); + + /// + /// Convenience for AddFileTab with DoNotAddToDockPanel = true. + /// Used by LoadProject for layout-restored windows. + /// + LogWindow AddFileTabDeferred (string fileName, bool isTempFile, string? title, bool forcePersistenceLoading, ILogLineMemoryColumnizer? preProcessColumnizer); + + /// + /// Creates a filter-result tab. Sets up filter tooltip on the LogWindowData. + /// + LogWindow AddFilterTab (FilterPipe pipe, string title, ILogLineMemoryColumnizer? preProcessColumnizer); + + /// + /// Creates a temp file tab. Convenience wrapper for AddFileTab with IsTempFile = true. + /// + LogWindow AddTempFileTab (string fileName, string title); + + /// + /// Creates a multi-file tab. + /// + LogWindow? AddMultiFileTab (string[] fileNames); + + /// + /// Public entry point that invokes AddFileTabs on the UI thread. + /// + void LoadFiles (string[] fileNames); + + /// + /// Resolves multi-file preference and returns a MultiFileDecision. + /// Does NOT show dialogs. Returns AskUser when the preference is Ask. + /// Sorts the file array for deterministic tab order. + /// + MultiFileDecision LoadFilesWithOption (string[] fileNames, bool invertLogic); + + /// + /// Iterates file names; routes .lxj files to a callback, adds all others via AddFileTab. + /// + void AddFileTabs (string[] fileNames); + + /// + /// Creates temp file from clipboard text, calls AddTempFileTab. + /// + LogWindow? PasteFromClipboard (); + + /// + /// Adds to ConfigManager history, raises FileHistoryChanged. + /// + void AddToFileHistory (string fileName); + + /// + /// Persists currently open non-temp files to ConfigManager. + /// + void SaveLastOpenFilesList (); + + /// + /// Loads startup files or last-open files. Startup files take precedence. + /// + void LoadStartupFiles (IList lastOpenFiles, string[]? startupFileNames); + + /// + /// Duplicate-tab detection, delegates to TabController. + /// + LogWindow? FindWindowForFile (string fileName); + + /// + /// Returns true if the data object contains DataFormats.FileDrop. + /// + bool CanHandleDrop (IDataObject data); + + /// + /// Raised after AddToFileHistory. LogTabWindow subscribes to refresh the history menu. + /// + event EventHandler? FileHistoryChanged; + + /// + /// Raised after a file tab is created. LogTabWindow subscribes for UI post-creation work. + /// The event args carry the original FileTabRequest plus resolved metadata. + /// + event EventHandler? FileOpened; +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs b/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs new file mode 100644 index 00000000..dd8b0491 --- /dev/null +++ b/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs @@ -0,0 +1,36 @@ +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Entities; +using LogExpert.UI.Controls.LogWindow; + +namespace LogExpert.UI.Services.FileOperationService; + +/// +/// Event args raised after a file tab is created. Carries the original request and +/// resolved metadata so LogTabWindow can perform UI-only post-creation work +/// (tooltip, tab coloring, multi-file BeginInvoke). +/// +/// +/// Designed to be stable as new creation variants are added. All creation context +/// is derived from the and optional extras, rather than +/// growing ad-hoc boolean properties. +/// +internal sealed class FileOpenedEventArgs : EventArgs +{ + /// The created LogWindow. + public required LogWindow LogWindow { get; init; } + + /// The original request that triggered the creation. + public required FileTabRequest Request { get; init; } + + /// The resolved log file name (after .lxp resolution). + public required string ResolvedFileName { get; init; } + + /// The encoding options resolved by the service. + public EncodingOptions? EncodingOptions { get; init; } + + /// Set when AddFilterTab created this tab. LogTabWindow uses this for filter tooltip setup. + public FilterPipe? FilterPipe { get; init; } + + /// Set when AddMultiFileTab created this tab. LogTabWindow uses this for BeginInvoke(LoadFilesAsMulti). + public string[]? MultiFileNames { get; init; } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs new file mode 100644 index 00000000..563338dc --- /dev/null +++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs @@ -0,0 +1,338 @@ +using System.Runtime.Versioning; +using System.Text; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Interface; + +using NLog; + +namespace LogExpert.UI.Services.FileOperationService; + +[SupportedOSPlatform("windows")] +internal sealed class FileOperationService ( + IConfigManager configManager, + ITabController tabController, + ILedIndicatorService ledIndicatorService, + IPluginRegistry pluginRegistry, + Func logWindowFactory, + Func clipboardTextProvider, + Action projectFileCallback) : IFileOperationService +{ + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + private readonly IConfigManager _configManager = configManager; + private readonly ITabController _tabController = tabController; + private readonly ILedIndicatorService _ledIndicatorService = ledIndicatorService; + private readonly IPluginRegistry _pluginRegistry = pluginRegistry; + private readonly Func _logWindowFactory = logWindowFactory; + private readonly Func _clipboardTextProvider = clipboardTextProvider; + private readonly Action _projectFileCallback = projectFileCallback; + + public event EventHandler? FileHistoryChanged; + public event EventHandler? FileOpened; + + + // TODO TabColor was part of older Versions, this is a feature that should be readded, sooner or later, but it is not a priority right now. + // var data = logWindow.Tag as LogWindowData; + // data.Color = _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; + // // } + // // } + // //} + + public LogWindow AddFileTab (FileTabRequest request) + { + var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry); + var normalizedPath = Path.GetFullPath(logFileName); + + var existingWindow = FindWindowForFile(normalizedPath); + if (existingWindow != null) + { + if (!request.IsTempFile) + { + AddToFileHistory(request.FileName); + } + + _tabController.ActivateWindow(existingWindow); + return existingWindow; + } + + EncodingOptions encodingOptions = new(); + FillDefaultEncodingFromSettings(encodingOptions); + + if (request.IsTempFile) + { + encodingOptions.Encoding = new UnicodeEncoding(false, false); + } + + var logWindow = _logWindowFactory(request, encodingOptions); + + if (!request.IsTempFile) + { + AddToFileHistory(request.FileName); + } + + if (request.FileName.EndsWith(".lxp", StringComparison.Ordinal)) + { + logWindow.ForcedPersistenceFileName = request.FileName; + } + + _ = Task.Run(() => logWindow.LoadFile(logFileName, encodingOptions)); + + FileOpened?.Invoke(this, new FileOpenedEventArgs + { + LogWindow = logWindow, + Request = request, + ResolvedFileName = logFileName, + EncodingOptions = encodingOptions + }); + + return logWindow; + } + + public LogWindow? FindWindowForFile (string fileName) + { + return _tabController.FindWindowByFileName(fileName); + } + + public void AddToFileHistory (string fileName) + { + _configManager.AddToFileHistory(fileName); + FileHistoryChanged?.Invoke(this, EventArgs.Empty); + } + + private void FillDefaultEncodingFromSettings (EncodingOptions encodingOptions) + { + if (_configManager.Settings.Preferences.DefaultEncoding != null) + { + try + { + encodingOptions.DefaultEncoding = Encoding.GetEncoding(_configManager.Settings.Preferences.DefaultEncoding); + } + catch (ArgumentException) + { + _logger.Warn($"### FillDefaultEncodingFromSettings: Encoding {_configManager.Settings.Preferences.DefaultEncoding} is not a valid encoding"); + encodingOptions.DefaultEncoding = null; + } + } + } + + public LogWindow AddFilterTab (FilterPipe pipe, string title, ILogLineMemoryColumnizer? preProcessColumnizer) + { + var request = new FileTabRequest + { + FileName = pipe.FileName, + IsTempFile = true, + Title = title, + PreProcessColumnizer = preProcessColumnizer + }; + + var logWindow = AddFileTab(request); + + // The filter-specific tooltip needs the pipe's FilterParams, which is not part of + // the generic FileTabRequest. We raise FileOpened again with the FilterPipe attached + // so LogTabWindow can set up the filter tooltip in OnFileOperationServiceFileOpened. + // Note: AddFileTab already raised FileOpened once (for tab color/tooltip setup). + // This second raise carries the FilterPipe for the filter-specific tooltip. + if (pipe.FilterParams.SearchText?.Length > 0) + { + FileOpened?.Invoke(this, new FileOpenedEventArgs + { + LogWindow = logWindow, + Request = request, + ResolvedFileName = pipe.FileName, + FilterPipe = pipe + }); + } + + return logWindow; + } + + public LogWindow AddTempFileTab (string fileName, string title) + { + return AddFileTab(new FileTabRequest + { + FileName = fileName, + IsTempFile = true, + Title = title + }); + } + + public LogWindow? AddMultiFileTab (string[] fileNames) + { + if (fileNames.Length < 1) + { + return null; + } + + EncodingOptions encodingOptions = new(); + FillDefaultEncodingFromSettings(encodingOptions); + + var request = new FileTabRequest + { + FileName = fileNames[^1], + IsTempFile = false + }; + + var logWindow = _logWindowFactory(request, encodingOptions); + + // BeginInvoke for LoadFilesAsMulti must be done by the caller (LogTabWindow) + // since it requires a Control. The factory already added the window to the DockPanel. + // We raise FileOpened with MultiFileNames so LogTabWindow can call BeginInvoke(logWindow.LoadFilesAsMulti, ...). + AddToFileHistory(fileNames[0]); + + FileOpened?.Invoke(this, new FileOpenedEventArgs + { + LogWindow = logWindow, + Request = request, + ResolvedFileName = fileNames[^1], + EncodingOptions = encodingOptions, + MultiFileNames = fileNames + }); + + return logWindow; + } + + public MultiFileDecision LoadFilesWithOption (string[] fileNames, bool invertLogic) + { + Array.Sort(fileNames); + + if (fileNames.Length == 1) + { + if (fileNames[0].EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)) + { + _projectFileCallback(fileNames[0], true); + return MultiFileDecision.Cancel; // Already handled + } + + _ = AddFileTab(new FileTabRequest { FileName = fileNames[0] }); + return MultiFileDecision.SingleFiles; + } + + var option = _configManager.Settings.Preferences.MultiFileOption; + + if (option == Core.Config.MultiFileOption.Ask) + { + return MultiFileDecision.AskUser; + } + + if (invertLogic) + { + option = option == Core.Config.MultiFileOption.SingleFiles + ? Core.Config.MultiFileOption.MultiFile + : Core.Config.MultiFileOption.SingleFiles; + } + + if (option == Core.Config.MultiFileOption.SingleFiles) + { + AddFileTabs(fileNames); + return MultiFileDecision.SingleFiles; + } + + _ = AddMultiFileTab(fileNames); + return MultiFileDecision.MultiFile; + } + + public void AddFileTabs (string[] fileNames) + { + foreach (var fileName in fileNames.Where(filename => !string.IsNullOrEmpty(filename))) + { + if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)) + { + _projectFileCallback(fileName, false); + } + else + { + _ = AddFileTab(new FileTabRequest { FileName = fileName }); + } + } + } + + public LogWindow? PasteFromClipboard () + { + var text = _clipboardTextProvider(); + if (string.IsNullOrEmpty(text)) + { + return null; + } + + var fileName = Path.GetTempFileName(); + + using (FileStream fStream = new(fileName, FileMode.Append, FileAccess.Write, FileShare.Read)) + using (StreamWriter writer = new(fStream, Encoding.Unicode)) + { + writer.Write(text); + writer.Close(); + } + + var title = "Clipboard"; // LogTabWindow will use the resource string via FileOpened event + return AddTempFileTab(fileName, title); + } + + public bool CanHandleDrop (IDataObject data) + { + return data?.GetDataPresent(DataFormats.FileDrop) == true; + } + + public LogWindow AddFileTabDeferred (string fileName, bool isTempFile, string? title, bool forcePersistenceLoading, ILogLineMemoryColumnizer? preProcessColumnizer) + { + return AddFileTab(new FileTabRequest + { + FileName = fileName, + IsTempFile = isTempFile, + Title = title, + ForcePersistenceLoading = forcePersistenceLoading, + PreProcessColumnizer = preProcessColumnizer, + DoNotAddToDockPanel = true + }); + } + + public void LoadFiles (string[] fileNames) + { + AddFileTabs(fileNames); + } + + public void SaveLastOpenFilesList () + { + foreach (var logWin in _tabController.GetAllWindowsFromDockPanel().Where(logwin => !logwin.IsTempFile)) + { + _configManager.Settings.LastOpenFilesList.Add(logWin.GivenFileName); + } + } + + public void LoadStartupFiles (IList lastOpenFiles, string[]? startupFileNames) + { + if (startupFileNames != null && startupFileNames.Length > 0) + { + _ = LoadFilesWithOption(startupFileNames, false); + return; + } + + if (_configManager.Settings.Preferences.OpenLastFiles) + { + foreach (var name in lastOpenFiles.Where(filename => !string.IsNullOrEmpty(filename))) + { + _ = AddFileTab(new FileTabRequest { FileName = name }); + } + } + + _configManager.ClearLastOpenFilesList(); + } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs b/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs new file mode 100644 index 00000000..972fe431 --- /dev/null +++ b/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs @@ -0,0 +1,39 @@ +using ColumnizerLib; + +namespace LogExpert.UI.Services.FileOperationService; + +/// +/// Parameter object for file tab creation. Replaces the 6 positional parameters on AddFileTab. +/// +internal sealed record FileTabRequest +{ + /// + /// The file name as provided by the caller (may be relative, may be a .lxp settings file). + /// + public required string FileName { get; init; } + + /// + /// Whether this is a temporary file (filter results, clipboard paste). + /// + public bool IsTempFile { get; init; } + + /// + /// Display title for temp file tabs. Ignored for non-temp files. + /// + public string? Title { get; init; } + + /// + /// Forces loading of persistence data (session restore). + /// + public bool ForcePersistenceLoading { get; init; } + + /// + /// Columnizer to apply before loading. Used for filter tabs. + /// + public ILogLineMemoryColumnizer? PreProcessColumnizer { get; init; } + + /// + /// If true, the window is tracked but not added to the DockPanel (deferred loading for layout restore). + /// + public bool DoNotAddToDockPanel { get; init; } +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/FileOperationService/MultiFileDecision.cs b/src/LogExpert.UI/Services/FileOperationService/MultiFileDecision.cs new file mode 100644 index 00000000..a8cbde81 --- /dev/null +++ b/src/LogExpert.UI/Services/FileOperationService/MultiFileDecision.cs @@ -0,0 +1,19 @@ +namespace LogExpert.UI.Services.FileOperationService; + +/// +/// Result of multi-file load decision logic. Describes the decision, not the UI action. +/// +internal enum MultiFileDecision +{ + /// Open each file in its own tab. + SingleFiles, + + /// Open all files in a single multi-file tab. + MultiFile, + + /// User cancelled the operation. + Cancel, + + /// User preference is Ask — caller must show a dialog and call back with the decision. + AskUser +} \ No newline at end of file diff --git a/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs b/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs index 13215b95..76f95e16 100644 --- a/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs +++ b/src/LogExpert.UI/Services/LogWindowCoordinatorService/LogWindowCoordinator.cs @@ -26,7 +26,8 @@ internal sealed class LogWindowCoordinator ( IPluginRegistry pluginRegistry, Controls.LogTabWindow.LogTabWindow logTabWindow, ITabController tabController, - ILedIndicatorService ledIndicatorService) : ILogWindowCoordinator + ILedIndicatorService ledIndicatorService, + IFileOperationService fileOperationService) : ILogWindowCoordinator { private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); @@ -35,6 +36,7 @@ internal sealed class LogWindowCoordinator ( private readonly Controls.LogTabWindow.LogTabWindow _logTabWindow = logTabWindow; private readonly ITabController _tabController = tabController; private readonly ILedIndicatorService _ledIndicatorService = ledIndicatorService; + private readonly IFileOperationService _fileOperationService = fileOperationService; private readonly Lock _highlightGroupLock = new(); private const int DIFF_MAX = 100; @@ -171,12 +173,12 @@ public HighlightGroup ResolveHighlightGroup (string? groupName, string? fileName public LogWindow AddFilterTab (FilterPipe pipe, string title, ILogLineMemoryColumnizer? preProcessColumnizer) { - return _logTabWindow.AddFilterTab(pipe, title, preProcessColumnizer); + return _fileOperationService.AddFilterTab(pipe, title, preProcessColumnizer); } public LogWindow AddTempFileTab (string fileName, string title) { - return _logTabWindow.AddTempFileTab(fileName, title); + return _fileOperationService.AddTempFileTab(fileName, title); } public void ScrollAllTabsToTimestamp (DateTime timestamp, LogWindow sender) diff --git a/src/LogExpert/LogExpert.csproj b/src/LogExpert/LogExpert.csproj index fcfb7228..0ad88329 100644 --- a/src/LogExpert/LogExpert.csproj +++ b/src/LogExpert/LogExpert.csproj @@ -14,6 +14,9 @@ + + PreserveNewest + Always diff --git a/src/LogExpert/NLog.config b/src/LogExpert/NLog.config index 3ef5612a..b5b27d18 100644 --- a/src/LogExpert/NLog.config +++ b/src/LogExpert/NLog.config @@ -4,7 +4,7 @@ - + diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 72fac169..9164253c 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-04-16 19:58:24 UTC + /// Generated: 2026-04-21 17:29:42 UTC /// Configuration: Release /// Plugin count: 22 /// @@ -18,28 +18,28 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "0B08628655B0073E6A6FB53DC19662526E5DE51753579E803C97B09FAAC76C4E", + ["AutoColumnizer.dll"] = "8E966C2C6671A03093867E2104C7C4E24F9D866F448AA5401F053F8BED23AFE1", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "92FD615A78C97D08286427E40D32C6081DFEFEC029F07A7E8BDF25199892883F", - ["CsvColumnizer.dll (x86)"] = "92FD615A78C97D08286427E40D32C6081DFEFEC029F07A7E8BDF25199892883F", - ["DefaultPlugins.dll"] = "87379D1A506345225623095555C02648AF771222797F3C55FB781286E31A19A6", - ["FlashIconHighlighter.dll"] = "1B8E7C9D4E3857B02B5BEF5F2D364860C6588E1FB0A01E955430D5A801AF0590", - ["GlassfishColumnizer.dll"] = "C0809AE0789B1D5C62EA50566DA60AF2EE0E4F08E22812F012185E4882409E5E", - ["JsonColumnizer.dll"] = "53A9B10E9F1E1A65C3C6B486232558941BDC368C4400687F963F2C19FDADF704", - ["JsonCompactColumnizer.dll"] = "41D19FDFDD28D535F540C98FDB783F472059DD8CF8EBFBE69E7BF55C9039B5B4", - ["Log4jXmlColumnizer.dll"] = "F7EBF4DA582A3A50A11B2DB738EE36B47DBE81F4DFD666476D516BEDCD46238F", - ["LogExpert.Core.dll"] = "8A14498D18E7109D0C38982A029CF60E53722968365A9A7CF679CF9BE3BE33F7", - ["LogExpert.Resources.dll"] = "7C4B4BE07D7929808B73059C3290AE831AC8BEEAC2ACE8F741F38034B3AB7D80", + ["CsvColumnizer.dll"] = "DCFCDBB094D328F246FD93809F4512C336B28584C4D1E7F13E73DDA26587572D", + ["CsvColumnizer.dll (x86)"] = "DCFCDBB094D328F246FD93809F4512C336B28584C4D1E7F13E73DDA26587572D", + ["DefaultPlugins.dll"] = "853FFDDD12B3F554E40476CE4E7C8A1543295FFEAFC445F31BD89DCC42597505", + ["FlashIconHighlighter.dll"] = "127F1C2C9E3FA58D24D52487BFBB69A44CDA5DE6A4AEBD2D61A259165C95E2F5", + ["GlassfishColumnizer.dll"] = "ECCE8D6B24DDD1016DF14F5DE8155FD2B92F27EBC877CDE9A7B0148C90AE60C5", + ["JsonColumnizer.dll"] = "C64E0E16204EEC4C012F4D02257A77A165EE1F3CE1A06EA3F525F6D005EF2B50", + ["JsonCompactColumnizer.dll"] = "849D1B630232C66F8C6311B82F0719D1F97714E55951CF38E8D696EFB39ACCD7", + ["Log4jXmlColumnizer.dll"] = "F1D80A33E4B61CCB20184BDA7CB141C4A481C9E780FE11408E09F475F5A2B9C3", + ["LogExpert.Core.dll"] = "1E1655F42A545573A908031FD5BB34FD434ECF377602D79A1CEFA87757746F1B", + ["LogExpert.Resources.dll"] = "E7D15126016C257E5B6E04E65538CDC12809835B6C88FC4E01E1532F7561DD98", ["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"] = "AEFA75BB2DC6DA4737DE966C05B15129EC0B058DBD49FBD7CBAA5F7DDF555406", - ["SftpFileSystem.dll"] = "10D577087C1E64AAA038DCB45E9557142846D47F87422D308882C2C3D7A44375", - ["SftpFileSystem.dll (x86)"] = "E52F4EF64341540A259660401D36E5DC24A75F976D4EE9D466DC174D049824B5", - ["SftpFileSystem.Resources.dll"] = "F663FEF53804EE82D60CD115B172B525C50FE4D3811794AAA71AFC22E61387DB", - ["SftpFileSystem.Resources.dll (x86)"] = "F663FEF53804EE82D60CD115B172B525C50FE4D3811794AAA71AFC22E61387DB", + ["RegexColumnizer.dll"] = "E26F22FDA3882B616B30E56446AF39E7563DA846ED7646F350CCB28DF7B01D56", + ["SftpFileSystem.dll"] = "D3069CD5F9F49ABB6912D22408E6812F59B8CF302A8C89D51E9651404CEAC482", + ["SftpFileSystem.dll (x86)"] = "E503DB9BA8770A853D61686605FE8DE265A813A5A22AB33FA4BB675803D10EF2", + ["SftpFileSystem.Resources.dll"] = "D1B38ACA3ADB0326C0D24BB36CEFCE0ADEED22D09B435A4884047DDA724E8901", + ["SftpFileSystem.Resources.dll (x86)"] = "D1B38ACA3ADB0326C0D24BB36CEFCE0ADEED22D09B435A4884047DDA724E8901", }; }