diff --git a/src/LogExpert.Configuration/ConfigManager.cs b/src/LogExpert.Configuration/ConfigManager.cs index 4b80b9e3..e4f3db67 100644 --- a/src/LogExpert.Configuration/ConfigManager.cs +++ b/src/LogExpert.Configuration/ConfigManager.cs @@ -329,6 +329,21 @@ public void RemoveFromFileHistory (string fileName) Save(SettingsFlags.FileHistory); } + /// + /// Removes every entry from the file history list and persists the result immediately. + /// + [SupportedOSPlatform("windows")] + public void ClearFileHistory () + { + // Held across the save so a concurrent save cannot serialise the list while it is being cleared. + lock (_loadSaveLock) + { + Instance.Settings.FileHistoryList.Clear(); + + Save(SettingsFlags.FileHistory); + } + } + public void ClearLastOpenFilesList () { lock (_loadSaveLock) diff --git a/src/LogExpert.Core/Interfaces/IConfigManager.cs b/src/LogExpert.Core/Interfaces/IConfigManager.cs index ac523daf..c19d5be9 100644 --- a/src/LogExpert.Core/Interfaces/IConfigManager.cs +++ b/src/LogExpert.Core/Interfaces/IConfigManager.cs @@ -182,6 +182,15 @@ public interface IConfigManager /// The name of the file to remove from the file history list. Comparison is case-insensitive. void RemoveFromFileHistory (string fileName); + /// + /// Removes all entries from the file history list. + /// + /// Only the file history list is cleared — the other history lists (search, filter, filter + /// range, URI, regex, columnizer) keep their entries. The settings are saved immediately; because + /// the save writes the whole settings file, the flag passed along names the change rather than + /// narrowing what is written. + void ClearFileHistory (); + /// /// Clears the list of recently opened files. /// diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs index 6bf4905d..5e9f174d 100644 --- a/src/LogExpert.Resources/Resources.Designer.cs +++ b/src/LogExpert.Resources/Resources.Designer.cs @@ -2383,6 +2383,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_cellSelectModeToolStripMe } } + /// + /// Looks up a localized string similar to Clear recently used. + /// + public static string LogTabWindow_UI_ToolStripMenuItem_clearFileHistoryToolStripMenuItem { + get { + return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_clearFileHistoryToolStripMenuItem", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close all tabs. /// diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 67ef82bf..736bc8a4 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -1240,6 +1240,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools Zuletzt benutzt + + Zuletzt benutzte löschen + Exit diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index bd9b3a53..441e4b87 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -1176,6 +1176,9 @@ Checked tools will appear in the icon bar. All other tools are available in the Last used + + Clear recently used + Exit diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 9b875814..58f62e03 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -1046,6 +1046,9 @@ 最近使用 + + 清除最近使用 + 退出 diff --git a/src/LogExpert.Tests/ConfigManagerTests/ClearFileHistoryTests.cs b/src/LogExpert.Tests/ConfigManagerTests/ClearFileHistoryTests.cs new file mode 100644 index 00000000..3bbdc5d0 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTests/ClearFileHistoryTests.cs @@ -0,0 +1,157 @@ +using System.Reflection; + +using LogExpert.Core.Config; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests.ConfigManagerTests; + +/// +/// Tests for the "clear recently used" action of the file history menu. +/// +[TestFixture] +public class ClearFileHistoryTests +{ + private string _testDir; + private Configuration.ConfigManager _configManager; + private object? _originalSettings; + private bool _originalIsInitialized; + private string _originalConfigDir; + + [SetUp] + public void SetUp () + { + _configManager = Configuration.ConfigManager.Instance; + + // Capture the singleton state before touching it. Other fixtures in this assembly rely on + // ConfigManager.Instance still pointing at the directory ConfigDirIsolationSetup prepared. + _originalIsInitialized = GetPrivateField("_isInitialized") is true; + _originalSettings = GetPrivateField("_settings"); + _originalConfigDir = _configManager.ConfigDir; + + _testDir = Path.Join(Path.GetTempPath(), "LogExpert_ClearHistoryTest_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(_testDir); + + ResetConfigManagerInitialization(); + _configManager.Initialize(_testDir, new Rectangle(0, 0, 1920, 1080)); + _configManager.ConfigDir = _testDir; + } + + [TearDown] + public void TearDown () + { + // Restore the singleton before removing the directory. While ConfigDir still points at this + // test's directory, the next fixture that saves recreates it and writes there instead of the + // shared isolation directory. + SetPrivateField("_isInitialized", _originalIsInitialized); + SetPrivateField("_settings", _originalSettings); + _configManager.ConfigDir = _originalConfigDir; + + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + catch (IOException) + { + // Ignore IO errors during cleanup + } + catch (UnauthorizedAccessException) + { + // Ignore access errors during cleanup + } + } + + [Test] + public void ClearFileHistory_RemovesAllEntries () + { + _configManager.AddToFileHistory(@"C:\logs\one.log"); + _configManager.AddToFileHistory(@"C:\logs\two.log"); + Assert.That(_configManager.Settings.FileHistoryList, Has.Count.EqualTo(2)); + + _configManager.ClearFileHistory(); + + Assert.That(_configManager.Settings.FileHistoryList, Is.Empty); + } + + [Test] + public void ClearFileHistory_WritesEmptyListToSettingsFile () + { + _configManager.AddToFileHistory(@"C:\logs\one.log"); + + _configManager.ClearFileHistory(); + + var settingsFile = Path.Join(_configManager.ActiveConfigDir, "settings.json"); + Assert.That(File.Exists(settingsFile), Is.True, "clearing the history has to persist immediately"); + + var saved = JsonConvert.DeserializeObject(File.ReadAllText(settingsFile)); + Assert.That(saved, Is.Not.Null); + Assert.That(saved.FileHistoryList, Is.Empty); + } + + /// + /// Clearing the recent files list leaves every other history list alone — including the ones the + /// settings dialog and the regex test dialog keep for their own inputs. + /// + [Test] + public void ClearFileHistory_LeavesOtherHistoryListsUntouched () + { + _configManager.AddToFileHistory(@"C:\logs\one.log"); + _configManager.Settings.LastOpenFilesList.Add(@"C:\logs\one.log"); + _configManager.Settings.SearchHistoryList.Add("error"); + _configManager.Settings.FilterHistoryList.Add("WARN"); + _configManager.Settings.FilterRangeHistoryList.Add("10"); + _configManager.Settings.UriHistoryList.Add("https://example.org/one.log"); + _configManager.Settings.RegexHistory.ExpressionHistoryList.Add(@"^\d+"); + _configManager.Settings.RegexHistory.TesttextHistoryList.Add("42"); + _configManager.Settings.ColumnizerHistoryList.Add(new ColumnizerHistoryEntry(@"C:\logs\one.log", "JsonColumnizer")); + + _configManager.ClearFileHistory(); + + Assert.Multiple(() => + { + Assert.That(_configManager.Settings.FileHistoryList, Is.Empty); + Assert.That(_configManager.Settings.LastOpenFilesList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.SearchHistoryList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.FilterHistoryList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.FilterRangeHistoryList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.UriHistoryList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.RegexHistory.ExpressionHistoryList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.RegexHistory.TesttextHistoryList, Has.Count.EqualTo(1)); + Assert.That(_configManager.Settings.ColumnizerHistoryList, Has.Count.EqualTo(1)); + }); + } + + /// + /// Resets the singleton so each test loads its settings from its own temp directory. + /// + private void ResetConfigManagerInitialization () + { + SetPrivateField("_isInitialized", false); + SetPrivateField("_settings", null); + } + + private object? GetPrivateField (string fieldName) + { + return Field(fieldName).GetValue(_configManager); + } + + private void SetPrivateField (string fieldName, object? value) + { + Field(fieldName).SetValue(_configManager, value); + } + + /// + /// Throws instead of returning null: a renamed field would otherwise make the reset silently do + /// nothing and the whole fixture pass against settings it never re-loaded. + /// + private static FieldInfo Field (string fieldName) + { + return typeof(Configuration.ConfigManager).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException($"ConfigManager.{fieldName} is gone; update this fixture's reset."); + } +} diff --git a/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs b/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs index f41d720d..ef06fc62 100644 --- a/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs +++ b/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs @@ -268,8 +268,45 @@ public void PopulateFileHistory_CreatesMenuItems () _controller.PopulateFileHistory(history); var lastUsed = FindMenuItem("lastUsedToolStripMenuItem"); - Assert.That(lastUsed.DropDownItems, Has.Count.EqualTo(2)); + Assert.That(lastUsed.DropDownItems, Has.Count.EqualTo(4), "2 files + separator + clear item"); Assert.That(lastUsed.DropDownItems[0].Text, Is.EqualTo(@"C:\log1.txt")); + Assert.That(lastUsed.DropDownItems[2], Is.TypeOf()); + Assert.That(lastUsed.DropDownItems[3].Name, Is.EqualTo("clearFileHistoryToolStripMenuItem")); + Assert.That(lastUsed.DropDownItems[3].Enabled, Is.True); + } + + [Test] + public void PopulateFileHistory_DisablesClearItem_WhenHistoryIsEmpty () + { + _controller.PopulateFileHistory([]); + + var lastUsed = FindMenuItem("lastUsedToolStripMenuItem"); + Assert.That(lastUsed.DropDownItems, Has.Count.EqualTo(1), "clear item only, no separator"); + Assert.That(lastUsed.DropDownItems[0].Name, Is.EqualTo("clearFileHistoryToolStripMenuItem")); + Assert.That(lastUsed.DropDownItems[0].Enabled, Is.False); + } + + [Test] + public void ClearHistoryItem_RaisesHistoryClearRequested_AndNotFileClicked () + { + var clearRequested = 0; + string clickedFile = null; + _controller.HistoryClearRequested += (_, _) => clearRequested++; + _controller.HistoryItemClicked += (_, e) => clickedFile = e.FileName; + + _controller.PopulateFileHistory(["test.log"]); + var lastUsed = FindMenuItem("lastUsedToolStripMenuItem"); + var clearItem = lastUsed.DropDownItems[^1]; + + var args = new ToolStripItemClickedEventArgs(clearItem); + var onItemClicked = typeof(ToolStrip).GetMethod("OnItemClicked", BindingFlags.Instance | BindingFlags.NonPublic); + _ = onItemClicked.Invoke(lastUsed.DropDown, [args]); + + Assert.Multiple(() => + { + Assert.That(clearRequested, Is.EqualTo(1)); + Assert.That(clickedFile, Is.Null, "the clear item must not be treated as a file entry"); + }); } [Test] diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 5ec992ae..828b012c 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -236,6 +236,7 @@ private void InitializeMenuToolbarControllerEvents () { _menuToolbarController.HistoryItemClicked += OnMenuControllerHistoryItemClicked; _menuToolbarController.HistoryItemRemoveRequested += OnMenuControllerHistoryItemRemoveRequested; + _menuToolbarController.HistoryClearRequested += OnMenuControllerHistoryClearRequested; _menuToolbarController.HighlightGroupSelected += OnMenuControllerHighlightGroupSelected; } @@ -250,6 +251,12 @@ private void OnMenuControllerHistoryItemRemoveRequested (object? sender, History FillHistoryMenu(); } + private void OnMenuControllerHistoryClearRequested (object? sender, EventArgs e) + { + ConfigManager.ClearFileHistory(); + FillHistoryMenu(); + } + private void OnMenuControllerHistoryItemClicked (object? sender, HistoryItemClickedEventArgs e) { _ = _fileOperationService.AddFileTab(new FileTabRequest { FileName = e.FileName }); diff --git a/src/LogExpert.UI/Interface/IMenuToolbarController.cs b/src/LogExpert.UI/Interface/IMenuToolbarController.cs index da3a921a..2aaed7a1 100644 --- a/src/LogExpert.UI/Interface/IMenuToolbarController.cs +++ b/src/LogExpert.UI/Interface/IMenuToolbarController.cs @@ -53,5 +53,6 @@ void InitializeMenus (MenuStrip mainMenu, ToolStrip buttonToolbar, ToolStrip ext // Events event EventHandler HistoryItemClicked; event EventHandler HistoryItemRemoveRequested; + event EventHandler HistoryClearRequested; event EventHandler HighlightGroupSelected; } diff --git a/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs b/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs index 5e1644f0..30613b45 100644 --- a/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs +++ b/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs @@ -14,6 +14,9 @@ namespace LogExpert.UI.Services.MenuToolbarService; [SupportedOSPlatform("windows")] internal sealed class MenuToolbarController : IMenuToolbarController { + // Identifies the row for tests and UI automation. The handlers do not look the item up by it. + private const string CLEAR_HISTORY_ITEM_NAME = "clearFileHistoryToolStripMenuItem"; + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); private MenuStrip _mainMenu; @@ -49,6 +52,11 @@ internal sealed class MenuToolbarController : IMenuToolbarController // History menu private ToolStripMenuItem _lastUsedMenuItem; + // The "clear recently used files" row of the history dropdown. The click handler tells it apart + // from the file entries by this reference; the right-click handler needs no special case, because + // only the file entries carry a Tag with a path. + private ToolStripMenuItem _clearHistoryItem; + private readonly SynchronizationContext _uiContext; private readonly int _uiThreadId = Environment.CurrentManagedThreadId; private bool _disposed; @@ -56,6 +64,7 @@ internal sealed class MenuToolbarController : IMenuToolbarController public event EventHandler HistoryItemClicked; public event EventHandler HistoryItemRemoveRequested; + public event EventHandler HistoryClearRequested; public event EventHandler HighlightGroupSelected; public MenuToolbarController () @@ -255,9 +264,12 @@ public void PopulateFileHistory (IEnumerable fileHistory) return; } + // Copy before the hop: callers hand over the live Settings.FileHistoryList, which can change + // while the post is still queued. if (Environment.CurrentManagedThreadId != _uiThreadId) { - _uiContext.Post(_ => PopulateFileHistory(fileHistory), null); + var snapshot = fileHistory.ToList(); + _uiContext.Post(_ => PopulateFileHistory(snapshot), null); return; } @@ -270,11 +282,27 @@ public void PopulateFileHistory (IEnumerable fileHistory) var strip = new ToolStripDropDownMenu(); - foreach (var file in fileHistory) + var history = fileHistory as IList ?? [.. fileHistory]; + + foreach (var file in history) { - _ = strip.Items.Add(new ToolStripMenuItem(file)); + // The path lives in Tag, so the handlers never have to treat the displayed text as data. + _ = strip.Items.Add(new ToolStripMenuItem(file) { Tag = file }); } + if (history.Count > 0) + { + _ = strip.Items.Add(new ToolStripSeparator()); + } + + _clearHistoryItem = new ToolStripMenuItem(Resources.LogTabWindow_UI_ToolStripMenuItem_clearFileHistoryToolStripMenuItem) + { + Name = CLEAR_HISTORY_ITEM_NAME, + Enabled = history.Count > 0 + }; + + _ = strip.Items.Add(_clearHistoryItem); + strip.ItemClicked += OnHistoryMenuItemClicked; strip.MouseUp += OnHistoryStripMouseUp; _lastUsedMenuItem.DropDown = strip; @@ -302,16 +330,26 @@ private void OnHighlightGroupComboSelectedIndexChanged (object sender, EventArgs private void OnHistoryMenuItemClicked (object sender, ToolStripItemClickedEventArgs e) { - var fileName = e.ClickedItem?.Text; - if (!string.IsNullOrEmpty(fileName)) + if (ReferenceEquals(e.ClickedItem, _clearHistoryItem)) + { + HistoryClearRequested?.Invoke(this, EventArgs.Empty); + return; + } + + if (e.ClickedItem?.Tag is string fileName && !string.IsNullOrEmpty(fileName)) { HistoryItemClicked?.Invoke(this, new HistoryItemClickedEventArgs(fileName)); } } + /// + /// Right-click on a file entry removes that file from the history. + /// + /// Separators and the clear row carry no file path, so they never reach the remove path. + /// Pre-existing: 's OnMouseUp override does not call the base + /// implementation, so the dropdown never raises this event. Behavior is unchanged here. private void OnHistoryStripMouseUp (object sender, MouseEventArgs e) { - // Right-click to remove from history (preserves existing LogTabWindow behavior) if (e.Button != MouseButtons.Right) { return; @@ -319,10 +357,9 @@ private void OnHistoryStripMouseUp (object sender, MouseEventArgs e) if (sender is ToolStripDropDownMenu strip) { - var item = strip.GetItemAt(e.Location); - if (item != null && !string.IsNullOrEmpty(item.Text)) + if (strip.GetItemAt(e.Location)?.Tag is string fileName && !string.IsNullOrEmpty(fileName)) { - HistoryItemRemoveRequested?.Invoke(this, new HistoryItemClickedEventArgs(item.Text)); + HistoryItemRemoveRequested?.Invoke(this, new HistoryItemClickedEventArgs(fileName)); } } }